diff --git a/build.gradle b/build.gradle index ad577bd..938ceb9 100644 --- a/build.gradle +++ b/build.gradle @@ -46,6 +46,7 @@ dependencies { // spring cloud implementation 'org.springframework.cloud:spring-cloud-starter-netflix-eureka-client' implementation 'org.springframework.cloud:spring-cloud-starter-openfeign' + implementation 'org.springframework.cloud:spring-cloud-starter-circuitbreaker-resilience4j' // lombok compileOnly 'org.projectlombok:lombok' diff --git a/src/main/java/com/wl2c/elswhereuserservice/client/product/api/ProductServiceClient.java b/src/main/java/com/wl2c/elswhereuserservice/client/product/api/ProductServiceClient.java index 4151e1d..758a0c7 100644 --- a/src/main/java/com/wl2c/elswhereuserservice/client/product/api/ProductServiceClient.java +++ b/src/main/java/com/wl2c/elswhereuserservice/client/product/api/ProductServiceClient.java @@ -1,13 +1,23 @@ package com.wl2c.elswhereuserservice.client.product.api; +import com.wl2c.elswhereuserservice.client.product.dto.request.RequestProductIdListDto; import com.wl2c.elswhereuserservice.client.product.dto.response.ResponseSingleProductDto; +import com.wl2c.elswhereuserservice.client.product.dto.response.ResponseSummarizedProductDto; +import jakarta.validation.Valid; import org.springframework.cloud.openfeign.FeignClient; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; + +import java.util.List; @FeignClient(name = "product-service") public interface ProductServiceClient { @GetMapping("/product/{productId}") ResponseSingleProductDto getProduct(@PathVariable Long productId); + + @PostMapping("/product/list") + List listByProductIds(@Valid @RequestBody RequestProductIdListDto requestProductIdListDto); } diff --git a/src/main/java/com/wl2c/elswhereuserservice/client/product/dto/request/RequestProductIdListDto.java b/src/main/java/com/wl2c/elswhereuserservice/client/product/dto/request/RequestProductIdListDto.java new file mode 100644 index 0000000..4c677c8 --- /dev/null +++ b/src/main/java/com/wl2c/elswhereuserservice/client/product/dto/request/RequestProductIdListDto.java @@ -0,0 +1,20 @@ +package com.wl2c.elswhereuserservice.client.product.dto.request; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonProperty; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Getter; + +import java.util.List; + +@Getter +public class RequestProductIdListDto { + + @Schema(description = "상품 id 리스트", example = "[3, 6, 9, 12]") + private final List productIdList; + + @JsonCreator + public RequestProductIdListDto(@JsonProperty("productIdList") List productIdList) { + this.productIdList = productIdList; + } +} diff --git a/src/main/java/com/wl2c/elswhereuserservice/client/product/dto/response/ResponseSummarizedProductDto.java b/src/main/java/com/wl2c/elswhereuserservice/client/product/dto/response/ResponseSummarizedProductDto.java new file mode 100644 index 0000000..13748d8 --- /dev/null +++ b/src/main/java/com/wl2c/elswhereuserservice/client/product/dto/response/ResponseSummarizedProductDto.java @@ -0,0 +1,41 @@ +package com.wl2c.elswhereuserservice.client.product.dto.response; + +import com.wl2c.elswhereuserservice.client.product.ProductType; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Builder; +import lombok.Getter; + +import java.math.BigDecimal; +import java.time.LocalDate; + +@Getter +@Builder +public class ResponseSummarizedProductDto { + @Schema(description = "상품 id", example = "1") + private final Long id; + + @Schema(description = "발행 회사", example = "oo투자증권") + private final String issuer; + + @Schema(description = "상품명", example = "oo투자증권 99999") + private final String name; + + @Schema(description = "상품 유형", example = "STEP_DOWN or LIZARD or MONTHLY_PAYMENT or ETC") + private final ProductType productType; + + @Schema(description = "기초자산", example = "KOSPI200 Index / HSCEI Index / S&P500 Index") + private final String equities; + + @Schema(description = "수익률", example = "20.55") + private final BigDecimal yieldIfConditionsMet; + + @Schema(description = "낙인 값", example = "45, 낙인 값이 없을 시 null return") + private final Integer knockIn; + + @Schema(description = "청약 시작일", example = "2024-06-14") + private final LocalDate subscriptionStartDate; + + @Schema(description = "청약 마감일", example = "2024-06-21") + private final LocalDate subscriptionEndDate; + +} diff --git a/src/main/java/com/wl2c/elswhereuserservice/domain/user/controller/UserInterestController.java b/src/main/java/com/wl2c/elswhereuserservice/domain/user/controller/UserInterestController.java new file mode 100644 index 0000000..e84f4d8 --- /dev/null +++ b/src/main/java/com/wl2c/elswhereuserservice/domain/user/controller/UserInterestController.java @@ -0,0 +1,58 @@ +package com.wl2c.elswhereuserservice.domain.user.controller; + +import com.wl2c.elswhereuserservice.client.product.dto.response.ResponseSummarizedProductDto; +import com.wl2c.elswhereuserservice.domain.user.model.dto.request.RequestCreateInterestDto; +import com.wl2c.elswhereuserservice.domain.user.model.dto.response.ResponseUserInterestDto; +import com.wl2c.elswhereuserservice.domain.user.service.UserInterestService; +import com.wl2c.elswhereuserservice.global.model.dto.ResponseIdDto; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +import static java.lang.Long.parseLong; + +@Tag(name = "사용자의 관심 상품", description = "사용자의 관심 상품 관련 api") +@RestController +@RequestMapping("/v1/interest") +@RequiredArgsConstructor +public class UserInterestController { + + private final UserInterestService userInterestService; + + /** + * 관심 상품 등록 + * + * @param dto 등록하고자 하는 상품 id + * @return 관심 상품 id + */ + @PostMapping + public ResponseIdDto create(HttpServletRequest request, + @Valid @RequestBody RequestCreateInterestDto dto) { + return userInterestService.create(parseLong(request.getHeader("requestId")), dto); + } + + /** + * 사용자의 관심 상품 리스트 조회 + * + * @return 사용자가 등록한 관심 상품 리스트 + */ + @GetMapping + public List read(HttpServletRequest request) { + return userInterestService.read(parseLong(request.getHeader("requestId"))); + } + + /** + * 특정 관심 상품 삭제 + * + * @param id 삭제할 관심 상품 id + */ + @DeleteMapping("/{id}") + public void delete(HttpServletRequest request, + @PathVariable Long id) { + userInterestService.delete(parseLong(request.getHeader("requestId")), id); + } +} diff --git a/src/main/java/com/wl2c/elswhereuserservice/domain/user/exception/AlreadyInterestException.java b/src/main/java/com/wl2c/elswhereuserservice/domain/user/exception/AlreadyInterestException.java new file mode 100644 index 0000000..d3dbfd3 --- /dev/null +++ b/src/main/java/com/wl2c/elswhereuserservice/domain/user/exception/AlreadyInterestException.java @@ -0,0 +1,10 @@ +package com.wl2c.elswhereuserservice.domain.user.exception; + +import com.wl2c.elswhereuserservice.global.exception.LocalizedMessageException; +import org.springframework.http.HttpStatus; + +public class AlreadyInterestException extends LocalizedMessageException { + public AlreadyInterestException() { + super(HttpStatus.BAD_REQUEST, "already.interest"); + } +} diff --git a/src/main/java/com/wl2c/elswhereuserservice/domain/user/exception/InterestNotFoundException.java b/src/main/java/com/wl2c/elswhereuserservice/domain/user/exception/InterestNotFoundException.java new file mode 100644 index 0000000..4ef3515 --- /dev/null +++ b/src/main/java/com/wl2c/elswhereuserservice/domain/user/exception/InterestNotFoundException.java @@ -0,0 +1,8 @@ +package com.wl2c.elswhereuserservice.domain.user.exception; + +import com.wl2c.elswhereuserservice.global.exception.LocalizedMessageException; +import org.springframework.http.HttpStatus; + +public class InterestNotFoundException extends LocalizedMessageException { + public InterestNotFoundException() { super(HttpStatus.NOT_FOUND, "notfound.interest-product"); } +} diff --git a/src/main/java/com/wl2c/elswhereuserservice/domain/user/model/dto/request/RequestCreateInterestDto.java b/src/main/java/com/wl2c/elswhereuserservice/domain/user/model/dto/request/RequestCreateInterestDto.java new file mode 100644 index 0000000..9fa48c0 --- /dev/null +++ b/src/main/java/com/wl2c/elswhereuserservice/domain/user/model/dto/request/RequestCreateInterestDto.java @@ -0,0 +1,20 @@ +package com.wl2c.elswhereuserservice.domain.user.model.dto.request; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonProperty; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Getter; + +import java.util.List; + +@Getter +public class RequestCreateInterestDto { + + @Schema(description = "상품 id", example = "3") + private final Long productId; + + @JsonCreator + public RequestCreateInterestDto(@JsonProperty("productId") Long productId) { + this.productId = productId; + } +} diff --git a/src/main/java/com/wl2c/elswhereuserservice/domain/user/model/dto/response/ResponseUserInterestDto.java b/src/main/java/com/wl2c/elswhereuserservice/domain/user/model/dto/response/ResponseUserInterestDto.java new file mode 100644 index 0000000..fbe813e --- /dev/null +++ b/src/main/java/com/wl2c/elswhereuserservice/domain/user/model/dto/response/ResponseUserInterestDto.java @@ -0,0 +1,60 @@ +package com.wl2c.elswhereuserservice.domain.user.model.dto.response; + +import com.wl2c.elswhereuserservice.client.product.ProductType; +import com.wl2c.elswhereuserservice.client.product.dto.response.ResponseSummarizedProductDto; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Builder; +import lombok.Getter; +import lombok.NonNull; + +import java.math.BigDecimal; +import java.time.LocalDate; + +@Getter +public class ResponseUserInterestDto { + + @Schema(description = "사용자 관심 상품 id", example = "1") + private final Long interestId; + + @Schema(description = "상품 id", example = "3") + private final Long productId; + + @Schema(description = "발행 회사", example = "oo투자증권") + private final String issuer; + + @Schema(description = "상품명", example = "oo투자증권 99999") + private final String name; + + @Schema(description = "상품 유형", example = "STEP_DOWN or LIZARD or MONTHLY_PAYMENT or ETC") + private final ProductType productType; + + @Schema(description = "기초자산", example = "KOSPI200 Index / HSCEI Index / S&P500 Index") + private final String equities; + + @Schema(description = "수익률", example = "20.55") + private final BigDecimal yieldIfConditionsMet; + + @Schema(description = "낙인 값", example = "45, 낙인 값이 없을 시 null return") + private final Integer knockIn; + + @Schema(description = "청약 시작일", example = "2024-06-14") + private final LocalDate subscriptionStartDate; + + @Schema(description = "청약 마감일", example = "2024-06-21") + private final LocalDate subscriptionEndDate; + + public ResponseUserInterestDto(@NonNull Long interestId, + @NonNull ResponseSummarizedProductDto responseSummarizedProductDto) { + this.interestId = interestId; + this.productId = responseSummarizedProductDto.getId(); + this.issuer = responseSummarizedProductDto.getIssuer(); + this.name = responseSummarizedProductDto.getName(); + this.productType = responseSummarizedProductDto.getProductType(); + this.equities = responseSummarizedProductDto.getEquities(); + this.yieldIfConditionsMet = responseSummarizedProductDto.getYieldIfConditionsMet(); + this.knockIn = responseSummarizedProductDto.getKnockIn(); + this.subscriptionStartDate = responseSummarizedProductDto.getSubscriptionStartDate(); + this.subscriptionEndDate = responseSummarizedProductDto.getSubscriptionEndDate(); + } + +} diff --git a/src/main/java/com/wl2c/elswhereuserservice/domain/user/model/entity/Interest.java b/src/main/java/com/wl2c/elswhereuserservice/domain/user/model/entity/Interest.java new file mode 100644 index 0000000..b507f95 --- /dev/null +++ b/src/main/java/com/wl2c/elswhereuserservice/domain/user/model/entity/Interest.java @@ -0,0 +1,39 @@ +package com.wl2c.elswhereuserservice.domain.user.model.entity; + +import com.wl2c.elswhereuserservice.domain.user.model.AlarmStatus; +import com.wl2c.elswhereuserservice.global.base.BaseEntity; +import jakarta.persistence.*; +import jakarta.validation.constraints.NotNull; +import lombok.*; + +import static jakarta.persistence.EnumType.STRING; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Interest extends BaseEntity { + + @Id + @GeneratedValue + @Column(name = "interest_id") + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id") + private User user; + + @NotNull + private Long productId; + + @Enumerated(STRING) + private AlarmStatus subscriptionEndDateAlarm; + + @Builder + public Interest(User user, + @NonNull Long productId) { + this.user = user; + this.productId = productId; + this.subscriptionEndDateAlarm = AlarmStatus.ACTIVE; + } + +} diff --git a/src/main/java/com/wl2c/elswhereuserservice/domain/user/model/entity/User.java b/src/main/java/com/wl2c/elswhereuserservice/domain/user/model/entity/User.java index d562a64..c1e3047 100644 --- a/src/main/java/com/wl2c/elswhereuserservice/domain/user/model/entity/User.java +++ b/src/main/java/com/wl2c/elswhereuserservice/domain/user/model/entity/User.java @@ -51,6 +51,9 @@ public class User extends BaseEntity { @OneToMany(mappedBy = "user", cascade = CascadeType.ALL, orphanRemoval = true) private List holdingList = new ArrayList<>(); + @OneToMany(mappedBy = "user", cascade = CascadeType.ALL, orphanRemoval = true) + private List interestList = new ArrayList<>(); + @Builder private User (@NonNull String socialId, @NonNull SocialType socialType, diff --git a/src/main/java/com/wl2c/elswhereuserservice/domain/user/repository/UserInterestRepository.java b/src/main/java/com/wl2c/elswhereuserservice/domain/user/repository/UserInterestRepository.java new file mode 100644 index 0000000..f209dd6 --- /dev/null +++ b/src/main/java/com/wl2c/elswhereuserservice/domain/user/repository/UserInterestRepository.java @@ -0,0 +1,28 @@ +package com.wl2c.elswhereuserservice.domain.user.repository; + +import com.wl2c.elswhereuserservice.domain.user.model.entity.Interest; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.List; +import java.util.Optional; + +public interface UserInterestRepository extends JpaRepository { + + @Modifying(clearAutomatically = true) + @Query("delete from Interest i " + + "where i.id = :id and i.user.id = :userId ") + void deleteInterest(@Param("userId") Long userId, + @Param("id") Long id); + + @Query("select i from Interest i where i.user.id = :userId ") + List findAllByUserId(@Param("userId") Long userId); + + @Query("select i from Interest i " + + "where i.user.id = :userId " + + "and i.productId = :productId ") + Optional findByUserIdAndProductId(@Param("userId") Long userId, + @Param("productId") Long productId); +} diff --git a/src/main/java/com/wl2c/elswhereuserservice/domain/user/service/UserInterestService.java b/src/main/java/com/wl2c/elswhereuserservice/domain/user/service/UserInterestService.java new file mode 100644 index 0000000..66c2716 --- /dev/null +++ b/src/main/java/com/wl2c/elswhereuserservice/domain/user/service/UserInterestService.java @@ -0,0 +1,103 @@ +package com.wl2c.elswhereuserservice.domain.user.service; + +import com.wl2c.elswhereuserservice.client.product.api.ProductServiceClient; +import com.wl2c.elswhereuserservice.client.product.dto.request.RequestProductIdListDto; +import com.wl2c.elswhereuserservice.client.product.dto.response.ResponseSingleProductDto; +import com.wl2c.elswhereuserservice.client.product.dto.response.ResponseSummarizedProductDto; +import com.wl2c.elswhereuserservice.client.product.exception.ProductNotFoundException; +import com.wl2c.elswhereuserservice.domain.user.exception.AlreadyInterestException; +import com.wl2c.elswhereuserservice.domain.user.exception.InterestNotFoundException; +import com.wl2c.elswhereuserservice.domain.user.exception.UserNotFoundException; +import com.wl2c.elswhereuserservice.domain.user.model.dto.request.RequestCreateInterestDto; +import com.wl2c.elswhereuserservice.domain.user.model.dto.response.ResponseUserInterestDto; +import com.wl2c.elswhereuserservice.domain.user.model.entity.Interest; +import com.wl2c.elswhereuserservice.domain.user.model.entity.User; +import com.wl2c.elswhereuserservice.domain.user.repository.UserInterestRepository; +import com.wl2c.elswhereuserservice.domain.user.repository.UserRepository; +import com.wl2c.elswhereuserservice.global.model.dto.ResponseIdDto; +import feign.FeignException; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.cloud.client.circuitbreaker.CircuitBreaker; +import org.springframework.cloud.client.circuitbreaker.CircuitBreakerFactory; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.ArrayList; +import java.util.List; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +@Slf4j +public class UserInterestService { + + private final CircuitBreakerFactory circuitBreakerFactory; + + private final UserRepository userRepository; + private final UserInterestRepository userInterestRepository; + + private final ProductServiceClient productServiceClient; + + @Transactional + public ResponseIdDto create(Long userId, RequestCreateInterestDto requestCreateInterestDto) { + + User user = userRepository.findById(userId).orElseThrow(UserNotFoundException::new); + + CircuitBreaker circuitBreaker = circuitBreakerFactory.create("interestCreateCircuitBreaker"); + circuitBreaker.run(() -> productServiceClient.getProduct(requestCreateInterestDto.getProductId()), + throwable -> new ProductNotFoundException()); + + if (userInterestRepository.findByUserIdAndProductId(userId, requestCreateInterestDto.getProductId()).isPresent()) { + throw new AlreadyInterestException(); + } else { + Interest interest = Interest.builder() + .user(user) + .productId(requestCreateInterestDto.getProductId()) + .build(); + userInterestRepository.save(interest); + + return new ResponseIdDto(interest.getId()); + } + } + + public List read(Long userId) { + List interestList = userInterestRepository.findAllByUserId(userId); + if (interestList.isEmpty()) { + throw new InterestNotFoundException(); + } + + List productIdList = interestList.stream() + .map(Interest::getProductId) + .toList(); + + CircuitBreaker circuitBreaker = circuitBreakerFactory.create("interestReadCircuitBreaker"); + List responseSummarizedProductDtoList = + circuitBreaker.run(() -> productServiceClient.listByProductIds(new RequestProductIdListDto(productIdList)), + throwable -> new ArrayList<>()); + + List result = new ArrayList<>(); + for (Interest interest : interestList) { + for (ResponseSummarizedProductDto responseSummarizedProductDto : responseSummarizedProductDtoList) { + if (interest.getProductId().equals(responseSummarizedProductDto.getId())) { + result.add(new ResponseUserInterestDto(interest.getId(), responseSummarizedProductDto)); + break; + } + } + } + + return result; + } + + @Transactional + public void delete(Long userId, Long interestId) { + Interest interest = userInterestRepository.findById(interestId).orElseThrow(InterestNotFoundException::new); + + if (interest.getUser().getId().equals(userId)) { + userInterestRepository.deleteInterest(userId, interestId); + } else { + throw new UserNotFoundException(); + } + } +} diff --git a/src/main/resources/errors.properties b/src/main/resources/errors.properties index cb032d5..f9c783f 100644 --- a/src/main/resources/errors.properties +++ b/src/main/resources/errors.properties @@ -4,6 +4,7 @@ invalid.expired-token=\uB9CC\uB8CC\uB41C \uD1A0\uD070\uC785\uB2C8\uB2E4. notfound.access-token=\uC561\uC138\uC2A4 \uD1A0\uD070\uC744 \uCC3E\uC744 \uC218 \uC5C6\uC2B5\uB2C8\uB2E4. notfound.user=\uC720\uC800\uB97C \uCC3E\uC744 \uC218 \uC5C6\uC2B5\uB2C8\uB2E4. notfound.holding-product=\uD604\uC7AC \uD574\uB2F9 \uBCF4\uC720\uC911\uC778 \uC0C1\uD488\uC744 \uCC3E\uC744 \uC218 \uC5C6\uC2B5\uB2C8\uB2E4. +notfound.interest-product=\uD604\uC7AC \uD574\uB2F9 \uAD00\uC2EC \uC0C1\uD488\uC744 \uCC3E\uC744 \uC218 \uC5C6\uC2B5\uB2C8\uB2E4. notfound.product=\uD574\uB2F9 \uC0C1\uD488\uC744 \uCC3E\uC744 \uC218 \uC5C6\uC2B5\uB2C8\uB2E4. unexpected=\uC608\uC0C1\uCE58 \uBABB\uD55C \uC624\uB958\uAC00 \uBC1C\uC0DD\uD588\uC2B5\uB2C8\uB2E4. @@ -13,4 +14,5 @@ required.access-token=\uC561\uC138\uC2A4 \uD1A0\uD070\uC774 \uD544\uC694\uD55C \ failed.oauth-callback-processing=OAuth \uCF5C\uBC31 \uC791\uC5C5\uC744 \uD558\uB294 \uB3C4\uC911 \uC624\uB958\uAC00 \uBC1C\uC0DD\uD588\uC2B5\uB2C8\uB2E4. failed.get-google-oauth-token=\uAD6C\uAE00\uB85C\uBD80\uD130 \uD1A0\uD070\uC744 \uBC1B\uB294\uB370 \uC2E4\uD328\uD588\uC2B5\uB2C8\uB2E4. -already.nickname=\uC774\uBBF8 \uC0AC\uC6A9\uC911\uC778 \uB2C9\uB124\uC784\uC785\uB2C8\uB2E4. \ No newline at end of file +already.nickname=\uC774\uBBF8 \uC0AC\uC6A9\uC911\uC778 \uB2C9\uB124\uC784\uC785\uB2C8\uB2E4. +already.interest=\uC774\uBBF8 \uB4F1\uB85D\uB41C \uAD00\uC2EC \uC0C1\uD488\uC785\uB2C8\uB2E4. \ No newline at end of file diff --git a/src/main/resources/errors_en_US.properties b/src/main/resources/errors_en_US.properties index f490efe..09df49f 100644 --- a/src/main/resources/errors_en_US.properties +++ b/src/main/resources/errors_en_US.properties @@ -4,6 +4,7 @@ invalid.expired-token=Expired token. notfound.access-token=Cannot find access-token notfound.user=Cannot find that user. notfound.holding-product=Cannot find that currently holding product +notfound.interest-product=Cannot find that currently interested product notfound.product=No such product was found. unexpected=An unexpected error occurred. @@ -13,4 +14,5 @@ required.access-token=This request requires access-token. failed.oauth-callback-processing=Exception occurred during OAuth callback processing. failed.get-google-oauth-token=Failed to receive token from Google. -already.nickname=The nickname is already in use. \ No newline at end of file +already.nickname=The nickname is already in use. +already.interest=It's already registered as a product of interest. \ No newline at end of file