diff --git a/build.gradle b/build.gradle index e8ec5591..7fa2acde 100644 --- a/build.gradle +++ b/build.gradle @@ -72,6 +72,9 @@ dependencies { // Discord Webhook implementation 'com.github.napstr:logback-discord-appender:1.0.0' + + // Spring Retry + implementation 'org.springframework.retry:spring-retry' } tasks.named('test') { @@ -93,4 +96,4 @@ tasks.register('copyYml', Copy) { include "*.yml" into 'src/main/resources' } -} \ No newline at end of file +} diff --git a/server-yml b/server-yml index ffaf66c6..924d55fa 160000 --- a/server-yml +++ b/server-yml @@ -1 +1 @@ -Subproject commit ffaf66c6224b53675d85223154b72999317670d7 +Subproject commit 924d55fa45cd8c04f376da6f83df374a90469eb8 diff --git a/src/main/java/org/hankki/hankkiserver/api/advice/GlobalExceptionHandler.java b/src/main/java/org/hankki/hankkiserver/api/advice/GlobalExceptionHandler.java index 1858acec..43e5d60c 100644 --- a/src/main/java/org/hankki/hankkiserver/api/advice/GlobalExceptionHandler.java +++ b/src/main/java/org/hankki/hankkiserver/api/advice/GlobalExceptionHandler.java @@ -7,6 +7,7 @@ import org.hankki.hankkiserver.common.code.StoreErrorCode; import org.hankki.hankkiserver.common.code.StoreImageErrorCode; import org.hankki.hankkiserver.common.exception.BadRequestException; +import org.hankki.hankkiserver.common.exception.ConcurrencyException; import org.hankki.hankkiserver.common.exception.ConflictException; import org.hankki.hankkiserver.common.exception.NotFoundException; import org.hankki.hankkiserver.common.exception.UnauthorizedException; @@ -49,6 +50,12 @@ public HankkiResponse handleConflictException(ConflictException e) { return HankkiResponse.fail(e.getErrorCode()); } + @ExceptionHandler(ConcurrencyException.class) + public HankkiResponse handleConcurrencyException(ConcurrencyException e) { + log.warn("handleConcurrencyException() in GlobalExceptionHandler throw ConcurrencyException : {}", e.getMessage()); + return HankkiResponse.fail(e.getErrorCode()); + } + @ExceptionHandler(MissingServletRequestParameterException.class) public HankkiResponse handleMissingServletRequestParameterException(MissingServletRequestParameterException e) { log.warn("handleMissingServletRequestParameterException() in GlobalExceptionHandler throw MissingServletRequestParameterException : {}", e.getMessage()); diff --git a/src/main/java/org/hankki/hankkiserver/api/config/RetryConfig.java b/src/main/java/org/hankki/hankkiserver/api/config/RetryConfig.java new file mode 100644 index 00000000..46c84e97 --- /dev/null +++ b/src/main/java/org/hankki/hankkiserver/api/config/RetryConfig.java @@ -0,0 +1,9 @@ +package org.hankki.hankkiserver.api.config; + +import org.springframework.context.annotation.Configuration; +import org.springframework.retry.annotation.EnableRetry; + +@EnableRetry +@Configuration +public class RetryConfig { +} diff --git a/src/main/java/org/hankki/hankkiserver/api/store/controller/StoreController.java b/src/main/java/org/hankki/hankkiserver/api/store/controller/StoreController.java index 44777968..e8e43d37 100644 --- a/src/main/java/org/hankki/hankkiserver/api/store/controller/StoreController.java +++ b/src/main/java/org/hankki/hankkiserver/api/store/controller/StoreController.java @@ -10,12 +10,32 @@ import org.hankki.hankkiserver.api.store.service.HeartCommandService; import org.hankki.hankkiserver.api.store.service.StoreCommandService; import org.hankki.hankkiserver.api.store.service.StoreQueryService; -import org.hankki.hankkiserver.api.store.service.command.*; -import org.hankki.hankkiserver.api.store.service.response.*; +import org.hankki.hankkiserver.api.store.service.command.HeartCommand; +import org.hankki.hankkiserver.api.store.service.command.StoreDeleteCommand; +import org.hankki.hankkiserver.api.store.service.command.StorePostCommand; +import org.hankki.hankkiserver.api.store.service.command.StoreValidationCommand; +import org.hankki.hankkiserver.api.store.service.response.CategoriesResponse; +import org.hankki.hankkiserver.api.store.service.response.HeartResponse; +import org.hankki.hankkiserver.api.store.service.response.PriceCategoriesResponse; +import org.hankki.hankkiserver.api.store.service.response.SortOptionsResponse; +import org.hankki.hankkiserver.api.store.service.response.StoreDuplicateValidationResponse; +import org.hankki.hankkiserver.api.store.service.response.StoreGetResponse; +import org.hankki.hankkiserver.api.store.service.response.StorePinsResponse; +import org.hankki.hankkiserver.api.store.service.response.StorePostResponse; +import org.hankki.hankkiserver.api.store.service.response.StoreThumbnailResponse; +import org.hankki.hankkiserver.api.store.service.response.StoresResponse; import org.hankki.hankkiserver.auth.UserId; import org.hankki.hankkiserver.common.code.CommonSuccessCode; import org.hankki.hankkiserver.domain.store.model.StoreCategory; -import org.springframework.web.bind.annotation.*; +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.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RequestPart; +import org.springframework.web.bind.annotation.RestController; import org.springframework.web.multipart.MultipartFile; @RestController @@ -42,9 +62,9 @@ public HankkiResponse getStorePins(@RequestParam(required = f @GetMapping("/stores") public HankkiResponse getStores(@RequestParam(required = false) final Long universityId, - @RequestParam(required = false) final StoreCategory storeCategory, - @RequestParam(required = false) final PriceCategory priceCategory, - @RequestParam(required = false) final SortOption sortOption) { + @RequestParam(required = false) final StoreCategory storeCategory, + @RequestParam(required = false) final PriceCategory priceCategory, + @RequestParam(required = false) final SortOption sortOption) { return HankkiResponse.success(CommonSuccessCode.OK, storeQueryService.getStores(universityId, storeCategory, priceCategory, sortOption)); } @@ -69,23 +89,28 @@ public HankkiResponse getPrices() { } @PostMapping("/stores/{id}/hearts") - public HankkiResponse createHeartStore(@UserId final Long userId, @PathVariable final Long id) { - return HankkiResponse.success(CommonSuccessCode.CREATED, heartCommandService.createHeart(HeartPostCommand.of(userId, id))); + public HankkiResponse createHeartStore(@UserId final Long userId, + @PathVariable final Long id) { + return HankkiResponse.success(CommonSuccessCode.CREATED, heartCommandService.createHeart(HeartCommand.of(userId, id))); } @DeleteMapping("/stores/{id}/hearts") - public HankkiResponse deleteHeartStore(@UserId final Long userId, @PathVariable final Long id) { - return HankkiResponse.success(CommonSuccessCode.OK, heartCommandService.deleteHeart(HeartDeleteCommand.of(userId, id))); + public HankkiResponse deleteHeartStore(@UserId final Long userId, + @PathVariable final Long id) { + return HankkiResponse.success(CommonSuccessCode.OK, heartCommandService.deleteHeart(HeartCommand.of(userId, id))); } @PostMapping("/stores/validate") - public HankkiResponse validateDuplicatedStore(@RequestBody @Valid final StoreDuplicateValidationRequest request) { - return HankkiResponse.success(CommonSuccessCode.OK, storeQueryService.validateDuplicatedStore(StoreValidationCommand.of(request.universityId(), request.latitude(), request.longitude(), request.storeName()))); + public HankkiResponse validateDuplicatedStore( + @RequestBody @Valid final StoreDuplicateValidationRequest request) { + return HankkiResponse.success(CommonSuccessCode.OK, storeQueryService.validateDuplicatedStore( + StoreValidationCommand.of(request.universityId(), request.latitude(), request.longitude(), + request.storeName()))); } @PostMapping("/stores") public HankkiResponse createStore(@RequestPart(required = false) final MultipartFile image, - @Valid @RequestPart final StorePostRequest request, + @Valid @RequestPart final StorePostRequest request, @UserId final Long userId) { return HankkiResponse.success(CommonSuccessCode.CREATED, storeCommandService.createStore(StorePostCommand.of(image, request, userId))); } diff --git a/src/main/java/org/hankki/hankkiserver/api/store/service/HeartCommandService.java b/src/main/java/org/hankki/hankkiserver/api/store/service/HeartCommandService.java index 592a982f..a84dd000 100644 --- a/src/main/java/org/hankki/hankkiserver/api/store/service/HeartCommandService.java +++ b/src/main/java/org/hankki/hankkiserver/api/store/service/HeartCommandService.java @@ -2,21 +2,25 @@ import lombok.RequiredArgsConstructor; import org.hankki.hankkiserver.api.auth.service.UserFinder; -import org.hankki.hankkiserver.api.store.service.command.HeartDeleteCommand; -import org.hankki.hankkiserver.api.store.service.command.HeartPostCommand; -import org.hankki.hankkiserver.api.store.service.response.HeartCreateResponse; -import org.hankki.hankkiserver.api.store.service.response.HeartDeleteResponse; +import org.hankki.hankkiserver.api.store.service.command.HeartCommand; +import org.hankki.hankkiserver.api.store.service.response.HeartResponse; import org.hankki.hankkiserver.common.code.HeartErrorCode; +import org.hankki.hankkiserver.common.exception.BadRequestException; +import org.hankki.hankkiserver.common.exception.ConcurrencyException; import org.hankki.hankkiserver.common.exception.ConflictException; +import org.hankki.hankkiserver.common.exception.NotFoundException; import org.hankki.hankkiserver.domain.heart.model.Heart; import org.hankki.hankkiserver.domain.store.model.Store; import org.hankki.hankkiserver.domain.user.model.User; +import org.springframework.orm.ObjectOptimisticLockingFailureException; +import org.springframework.retry.annotation.Backoff; +import org.springframework.retry.annotation.Recover; +import org.springframework.retry.annotation.Retryable; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @Service @RequiredArgsConstructor -@Transactional public class HeartCommandService { private final HeartUpdater heartUpdater; @@ -25,32 +29,48 @@ public class HeartCommandService { private final UserFinder userFinder; private final StoreFinder storeFinder; - public HeartCreateResponse createHeart(final HeartPostCommand heartPostCommand) { - User user = userFinder.getUserReference(heartPostCommand.userId()); - Store store = storeFinder.findByIdWhereDeletedIsFalse(heartPostCommand.storeId()); + @Retryable( + noRetryFor = {ConflictException.class, NotFoundException.class}, + notRecoverable = {ConflictException.class, NotFoundException.class}, + backoff = @Backoff(delay = 100)) + @Transactional + public HeartResponse createHeart(final HeartCommand heartCommand) { + User user = userFinder.getUserReference(heartCommand.userId()); + Store store = storeFinder.findByIdWhereDeletedIsFalse(heartCommand.storeId()); validateStoreHeartCreation(user, store); saveStoreHeart(user, store); store.increaseHeartCount(); - return HeartCreateResponse.of(store); + return HeartResponse.of(store); } - public HeartDeleteResponse deleteHeart(final HeartDeleteCommand heartDeleteCommand) { - User user = userFinder.getUserReference(heartDeleteCommand.userId()); - Store store = storeFinder.findByIdWhereDeletedIsFalse(heartDeleteCommand.storeId()); + @Retryable( + noRetryFor = {NotFoundException.class, ConflictException.class, BadRequestException.class}, + notRecoverable = {NotFoundException.class, ConflictException.class, BadRequestException.class}, + backoff = @Backoff(delay = 100)) + @Transactional + public HeartResponse deleteHeart(final HeartCommand heartCommand) { + User user = userFinder.getUserReference(heartCommand.userId()); + Store store = storeFinder.findByIdWhereDeletedIsFalse(heartCommand.storeId()); validateStoreHeartRemoval(user, store); - heartDeleter.deleteHeart(user,store); + heartDeleter.deleteHeart(user, store); store.decreaseHeartCount(); - return HeartDeleteResponse.of(store); + return HeartResponse.of(store); + } + + @Recover + public HeartResponse recoverFromOptimisticLockFailure(final ObjectOptimisticLockingFailureException e, + final HeartCommand heartCommand) { + throw new ConcurrencyException(HeartErrorCode.HEART_COUNT_CONCURRENCY_ERROR); } private void validateStoreHeartCreation(final User user, final Store store) { - if(heartFinder.existsByUserAndStore(user, store)){ + if (heartFinder.existsByUserAndStore(user, store)) { throw new ConflictException(HeartErrorCode.ALREADY_EXISTED_HEART); } } private void validateStoreHeartRemoval(final User user, final Store store) { - if(!heartFinder.existsByUserAndStore(user, store)){ + if (!heartFinder.existsByUserAndStore(user, store)) { throw new ConflictException(HeartErrorCode.ALREADY_NO_HEART); } } diff --git a/src/main/java/org/hankki/hankkiserver/api/store/service/command/HeartCommand.java b/src/main/java/org/hankki/hankkiserver/api/store/service/command/HeartCommand.java new file mode 100644 index 00000000..e5467e53 --- /dev/null +++ b/src/main/java/org/hankki/hankkiserver/api/store/service/command/HeartCommand.java @@ -0,0 +1,10 @@ +package org.hankki.hankkiserver.api.store.service.command; + +public record HeartCommand( + Long userId, + Long storeId +) { + public static HeartCommand of(final Long userId, final Long storeId) { + return new HeartCommand(userId, storeId); + } +} diff --git a/src/main/java/org/hankki/hankkiserver/api/store/service/command/HeartDeleteCommand.java b/src/main/java/org/hankki/hankkiserver/api/store/service/command/HeartDeleteCommand.java deleted file mode 100644 index 5850af17..00000000 --- a/src/main/java/org/hankki/hankkiserver/api/store/service/command/HeartDeleteCommand.java +++ /dev/null @@ -1,11 +0,0 @@ -package org.hankki.hankkiserver.api.store.service.command; - -public record HeartDeleteCommand( - Long userId, - Long storeId -) { - public static HeartDeleteCommand of(final Long userId, final Long storeId) { - return new HeartDeleteCommand(userId, storeId); - } -} - diff --git a/src/main/java/org/hankki/hankkiserver/api/store/service/command/HeartPostCommand.java b/src/main/java/org/hankki/hankkiserver/api/store/service/command/HeartPostCommand.java deleted file mode 100644 index 7b07c445..00000000 --- a/src/main/java/org/hankki/hankkiserver/api/store/service/command/HeartPostCommand.java +++ /dev/null @@ -1,10 +0,0 @@ -package org.hankki.hankkiserver.api.store.service.command; - -public record HeartPostCommand( - Long userId, - Long storeId -) { - public static HeartPostCommand of(final Long userId, final Long storeId) { - return new HeartPostCommand(userId, storeId); - } -} diff --git a/src/main/java/org/hankki/hankkiserver/api/store/service/response/HeartDeleteResponse.java b/src/main/java/org/hankki/hankkiserver/api/store/service/response/HeartDeleteResponse.java deleted file mode 100644 index 1ee95583..00000000 --- a/src/main/java/org/hankki/hankkiserver/api/store/service/response/HeartDeleteResponse.java +++ /dev/null @@ -1,13 +0,0 @@ -package org.hankki.hankkiserver.api.store.service.response; - -import org.hankki.hankkiserver.domain.store.model.Store; - -public record HeartDeleteResponse( - Long storeId, - int count, - boolean isHearted -) { - public static HeartDeleteResponse of(final Store store) { - return new HeartDeleteResponse(store.getId(), store.getHeartCount(), false); - } -} diff --git a/src/main/java/org/hankki/hankkiserver/api/store/service/response/HeartCreateResponse.java b/src/main/java/org/hankki/hankkiserver/api/store/service/response/HeartResponse.java similarity index 52% rename from src/main/java/org/hankki/hankkiserver/api/store/service/response/HeartCreateResponse.java rename to src/main/java/org/hankki/hankkiserver/api/store/service/response/HeartResponse.java index 952de5f9..1255ab7c 100644 --- a/src/main/java/org/hankki/hankkiserver/api/store/service/response/HeartCreateResponse.java +++ b/src/main/java/org/hankki/hankkiserver/api/store/service/response/HeartResponse.java @@ -2,12 +2,12 @@ import org.hankki.hankkiserver.domain.store.model.Store; -public record HeartCreateResponse( +public record HeartResponse( Long storeId, int count, boolean isHearted ) { - public static HeartCreateResponse of(final Store store) { - return new HeartCreateResponse(store.getId(), store.getHeartCount(), true); + public static HeartResponse of(final Store store) { + return new HeartResponse(store.getId(), store.getHeartCount(), true); } } diff --git a/src/main/java/org/hankki/hankkiserver/common/code/HeartErrorCode.java b/src/main/java/org/hankki/hankkiserver/common/code/HeartErrorCode.java index 2578d04c..320bddcd 100644 --- a/src/main/java/org/hankki/hankkiserver/common/code/HeartErrorCode.java +++ b/src/main/java/org/hankki/hankkiserver/common/code/HeartErrorCode.java @@ -9,7 +9,9 @@ public enum HeartErrorCode implements ErrorCode { ALREADY_EXISTED_HEART(HttpStatus.CONFLICT, "이미 좋아요 한 가게입니다."), - ALREADY_NO_HEART(HttpStatus.CONFLICT, "이미 좋아요를 취소한 가게입니다."); + ALREADY_NO_HEART(HttpStatus.CONFLICT, "이미 좋아요를 취소한 가게입니다."), + HEART_COUNT_CONCURRENCY_ERROR(HttpStatus.CONFLICT, "좋아요 처리 중 충돌이 발생했습니다. 잠시 후 다시 시도해주세요."), + INVALID_HEART_COUNT(HttpStatus.BAD_REQUEST, "좋아요 개수는 음수가 될 수 없습니다."); private final HttpStatus httpStatus; private final String message; diff --git a/src/main/java/org/hankki/hankkiserver/common/exception/ConcurrencyException.java b/src/main/java/org/hankki/hankkiserver/common/exception/ConcurrencyException.java new file mode 100644 index 00000000..bb6b27cf --- /dev/null +++ b/src/main/java/org/hankki/hankkiserver/common/exception/ConcurrencyException.java @@ -0,0 +1,13 @@ +package org.hankki.hankkiserver.common.exception; + +import lombok.Getter; +import org.hankki.hankkiserver.common.code.ErrorCode; + +@Getter +public class ConcurrencyException extends RuntimeException { + private final ErrorCode errorCode; + + public ConcurrencyException(final ErrorCode errorCode) { + this.errorCode = errorCode; + } +} diff --git a/src/main/java/org/hankki/hankkiserver/domain/store/model/Store.java b/src/main/java/org/hankki/hankkiserver/domain/store/model/Store.java index 78345239..9e2a942d 100644 --- a/src/main/java/org/hankki/hankkiserver/domain/store/model/Store.java +++ b/src/main/java/org/hankki/hankkiserver/domain/store/model/Store.java @@ -1,25 +1,40 @@ package org.hankki.hankkiserver.domain.store.model; -import jakarta.persistence.*; +import jakarta.persistence.Column; +import jakarta.persistence.Embedded; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.OneToMany; +import jakarta.persistence.Version; +import java.util.ArrayList; +import java.util.List; import lombok.AccessLevel; import lombok.Builder; import lombok.Getter; import lombok.NoArgsConstructor; +import org.hankki.hankkiserver.common.code.HeartErrorCode; +import org.hankki.hankkiserver.common.exception.BadRequestException; import org.hankki.hankkiserver.domain.common.BaseTimeEntity; import org.hankki.hankkiserver.domain.common.Point; import org.hankki.hankkiserver.domain.heart.model.Heart; import org.hankki.hankkiserver.domain.universitystore.model.UniversityStore; import org.hibernate.annotations.BatchSize; - -import java.util.ArrayList; -import java.util.List; +import org.hibernate.annotations.ColumnDefault; +import org.hibernate.annotations.DynamicInsert; @Entity @Getter @BatchSize(size = 100) @NoArgsConstructor(access = AccessLevel.PROTECTED) +@DynamicInsert public class Store extends BaseTimeEntity { + private static final int DEFAULT_HEART_COUNT = 0; + @Id @Column(name = "store_id") @GeneratedValue(strategy = GenerationType.IDENTITY) @@ -57,8 +72,13 @@ public class Store extends BaseTimeEntity { @Column(nullable = false) private boolean isDeleted; + @Version + @ColumnDefault("0L") + private Long version; + @Builder - private Store (String name, Point point, String address, StoreCategory category, int lowestPrice, int heartCount, boolean isDeleted) { + private Store(String name, Point point, String address, StoreCategory category, int lowestPrice, int heartCount, + boolean isDeleted) { this.name = name; this.point = point; this.address = address; @@ -69,6 +89,7 @@ private Store (String name, Point point, String address, StoreCategory category, } public void decreaseHeartCount() { + validateHeartCount(); this.heartCount--; } @@ -87,4 +108,10 @@ public String getImageUrlOrElseNull() { public void updateLowestPrice(int lowestPrice) { this.lowestPrice = lowestPrice; } + + private void validateHeartCount() { + if (this.heartCount <= DEFAULT_HEART_COUNT) { + throw new BadRequestException(HeartErrorCode.INVALID_HEART_COUNT); + } + } } diff --git a/src/main/java/org/hankki/hankkiserver/domain/store/repository/StoreRepository.java b/src/main/java/org/hankki/hankkiserver/domain/store/repository/StoreRepository.java index a14d0027..b2774044 100644 --- a/src/main/java/org/hankki/hankkiserver/domain/store/repository/StoreRepository.java +++ b/src/main/java/org/hankki/hankkiserver/domain/store/repository/StoreRepository.java @@ -1,8 +1,10 @@ package org.hankki.hankkiserver.domain.store.repository; +import jakarta.persistence.LockModeType; import org.hankki.hankkiserver.domain.favoritestore.model.FavoriteStore; import org.hankki.hankkiserver.domain.store.model.Store; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Lock; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; @@ -10,6 +12,8 @@ import java.util.Optional; public interface StoreRepository extends JpaRepository, CustomStoreRepository { + + @Lock(LockModeType.OPTIMISTIC) @Query("select s from Store s where s.id = :id and s.isDeleted = false") Optional findByIdAndIsDeletedIsFalse(Long id); @@ -23,4 +27,4 @@ public interface StoreRepository extends JpaRepository, CustomStore @Query("select s from Store s join FavoriteStore fs on s.id = fs.store.id where fs in :favoriteStores and s.isDeleted = false order by fs.id desc ") List findAllByFavoriteStoresAndIsDeletedIsFalseOrderByFavoriteStoreId(@Param("favoriteStores") List favoriteStores); -} \ No newline at end of file +}