diff --git a/src/main/java/org/runimo/runimo/item/domain/ActivityType.java b/src/main/java/org/runimo/runimo/item/domain/ActivityType.java new file mode 100644 index 00000000..b966eca9 --- /dev/null +++ b/src/main/java/org/runimo/runimo/item/domain/ActivityType.java @@ -0,0 +1,8 @@ +package org.runimo.runimo.item.domain; + +public enum ActivityType { + GAIN, + CONSUME, + REFUND, + EXPIRE +} diff --git a/src/main/java/org/runimo/runimo/item/domain/Item.java b/src/main/java/org/runimo/runimo/item/domain/Item.java new file mode 100644 index 00000000..df0443b9 --- /dev/null +++ b/src/main/java/org/runimo/runimo/item/domain/Item.java @@ -0,0 +1,36 @@ +package org.runimo.runimo.item.domain; + +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.NaturalId; +import org.runimo.runimo.common.BaseEntity; + +@Entity +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Item extends BaseEntity { + + @NaturalId + private String itemCode; + + private String name; + + private String description; + + private String imgUrl; + + @Enumerated(EnumType.STRING) + private ItemType itemType; + + @Builder + public Item(String itemCode, String name, String description, String imgUrl, ItemType itemType) { + this.itemCode = itemCode; + this.name = name; + this.description = description; + this.imgUrl = imgUrl; + this.itemType = itemType; + } +} diff --git a/src/main/java/org/runimo/runimo/item/domain/ItemActivity.java b/src/main/java/org/runimo/runimo/item/domain/ItemActivity.java new file mode 100644 index 00000000..a618e2a4 --- /dev/null +++ b/src/main/java/org/runimo/runimo/item/domain/ItemActivity.java @@ -0,0 +1,32 @@ +package org.runimo.runimo.item.domain; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.runimo.runimo.common.BaseEntity; + +// append only entity +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class ItemActivity extends BaseEntity { + @Column(name = "activity_user_id", nullable = false) + private Long userId; + @Column(name = "activity_event_id", nullable = false) + private Long itemId; + + private Long quantity; + @Column(name = "activity_event_type", nullable = false) + private ActivityType type; + + @Builder + public ItemActivity(Long userId, Long itemId, Long quantity, ActivityType type) { + this.userId = userId; + this.itemId = itemId; + this.quantity = quantity; + this.type = type; + } +} diff --git a/src/main/java/org/runimo/runimo/item/domain/ItemType.java b/src/main/java/org/runimo/runimo/item/domain/ItemType.java new file mode 100644 index 00000000..e366cce1 --- /dev/null +++ b/src/main/java/org/runimo/runimo/item/domain/ItemType.java @@ -0,0 +1,7 @@ +package org.runimo.runimo.item.domain; + +public enum ItemType { + USABLE, + EQUIPMENT, + ETC +} diff --git a/src/main/java/org/runimo/runimo/item/repository/ItemActivityRepository.java b/src/main/java/org/runimo/runimo/item/repository/ItemActivityRepository.java new file mode 100644 index 00000000..622ff311 --- /dev/null +++ b/src/main/java/org/runimo/runimo/item/repository/ItemActivityRepository.java @@ -0,0 +1,9 @@ +package org.runimo.runimo.item.repository; + +import org.runimo.runimo.item.domain.ItemActivity; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface ItemActivityRepository extends JpaRepository { +} diff --git a/src/main/java/org/runimo/runimo/item/repository/ItemRepository.java b/src/main/java/org/runimo/runimo/item/repository/ItemRepository.java new file mode 100644 index 00000000..f60e5497 --- /dev/null +++ b/src/main/java/org/runimo/runimo/item/repository/ItemRepository.java @@ -0,0 +1,15 @@ +package org.runimo.runimo.item.repository; + +import org.runimo.runimo.item.domain.Item; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.stereotype.Repository; + +import java.util.List; + +@Repository +public interface ItemRepository extends JpaRepository { + + @Query("select i.id from Item i") + List findAllItemIds(); +} diff --git a/src/main/java/org/runimo/runimo/item/service/ItemActivityCreator.java b/src/main/java/org/runimo/runimo/item/service/ItemActivityCreator.java new file mode 100644 index 00000000..284668d2 --- /dev/null +++ b/src/main/java/org/runimo/runimo/item/service/ItemActivityCreator.java @@ -0,0 +1,7 @@ +package org.runimo.runimo.item.service; + +import org.runimo.runimo.item.service.dtos.CreateActivityCommand; + +public interface ItemActivityCreator { + void createItemActivity(CreateActivityCommand command); +} diff --git a/src/main/java/org/runimo/runimo/item/service/ItemActivityCreatorImpl.java b/src/main/java/org/runimo/runimo/item/service/ItemActivityCreatorImpl.java new file mode 100644 index 00000000..e8d68079 --- /dev/null +++ b/src/main/java/org/runimo/runimo/item/service/ItemActivityCreatorImpl.java @@ -0,0 +1,27 @@ +package org.runimo.runimo.item.service; + +import lombok.RequiredArgsConstructor; +import org.runimo.runimo.item.repository.ItemActivityRepository; +import org.runimo.runimo.item.domain.ItemActivity; +import org.runimo.runimo.item.service.dtos.CreateActivityCommand; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class ItemActivityCreatorImpl implements ItemActivityCreator { + + private final ItemActivityRepository itemActivityRepository; + + @Override + @Transactional + public void createItemActivity(CreateActivityCommand command) { + ItemActivity activity = ItemActivity.builder() + .userId(command.userId()) + .itemId(command.itemId()) + .quantity(command.quantity()) + .type(command.activityType()) + .build(); + itemActivityRepository.save(activity); + } +} diff --git a/src/main/java/org/runimo/runimo/item/service/ItemFinder.java b/src/main/java/org/runimo/runimo/item/service/ItemFinder.java new file mode 100644 index 00000000..74a95389 --- /dev/null +++ b/src/main/java/org/runimo/runimo/item/service/ItemFinder.java @@ -0,0 +1,23 @@ +package org.runimo.runimo.item.service; + +import lombok.RequiredArgsConstructor; +import org.runimo.runimo.item.domain.Item; +import org.runimo.runimo.item.repository.ItemRepository; +import org.springframework.stereotype.Component; + +import java.util.Optional; + +@Component +@RequiredArgsConstructor +public class ItemFinder { + + private final ItemRepository itemRepository; + + public Optional findById(Long itemId) { + return itemRepository.findById(itemId); + } + + public Boolean isItemExist(Long itemId) { + return itemRepository.existsById(itemId); + } +} diff --git a/src/main/java/org/runimo/runimo/item/service/dtos/CreateActivityCommand.java b/src/main/java/org/runimo/runimo/item/service/dtos/CreateActivityCommand.java new file mode 100644 index 00000000..1ddb4ee7 --- /dev/null +++ b/src/main/java/org/runimo/runimo/item/service/dtos/CreateActivityCommand.java @@ -0,0 +1,11 @@ +package org.runimo.runimo.item.service.dtos; + +import org.runimo.runimo.item.domain.ActivityType; + +public record CreateActivityCommand( + Long itemId, + Long userId, + Long quantity, + ActivityType activityType +) { +} diff --git a/src/main/java/org/runimo/runimo/user/controller/UserController.java b/src/main/java/org/runimo/runimo/user/controller/UserController.java index 066d69e0..a3bb2850 100644 --- a/src/main/java/org/runimo/runimo/user/controller/UserController.java +++ b/src/main/java/org/runimo/runimo/user/controller/UserController.java @@ -11,11 +11,11 @@ import org.runimo.runimo.common.response.SuccessResponse; import org.runimo.runimo.user.controller.request.AuthLoginRequest; import org.runimo.runimo.user.controller.request.AuthSignupRequest; +import org.runimo.runimo.user.controller.request.UseItemRequest; import org.runimo.runimo.user.domain.SocialProvider; import org.runimo.runimo.user.enums.UserHttpResponseCode; -import org.runimo.runimo.user.service.dtos.AuthResponse; -import org.runimo.runimo.user.service.dtos.SignupUserInfo; -import org.runimo.runimo.user.service.dtos.TokenPair; +import org.runimo.runimo.user.service.dtos.*; +import org.runimo.runimo.user.service.usecases.UseItemUsecase; import org.runimo.runimo.user.service.usecases.UserOAuthUsecase; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.PostMapping; @@ -27,11 +27,12 @@ @Tag(name = "USER", description = "사용자 관련 API") @RestController -@RequestMapping("/api/v1/user") +@RequestMapping("/api/v1/users") @RequiredArgsConstructor public class UserController { private final UserOAuthUsecase userOAuthUsecase; + private final UseItemUsecase useItemUsecase; @Operation(summary = "사용자 로그인", description = "사용자가 OIDC 토큰을 사용하여 로그인합니다.") @ApiResponses(value = { @@ -75,4 +76,27 @@ public ResponseEntity> signupAndLogin( UserHttpResponseCode.SIGNUP_SUCCESS, new AuthResponse(authResult.tokenPair()))); } + + @Operation(summary = "아이템 사용", description = "사용자가 아이템을 사용합니다.") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "아이템 사용 성공", + content = @Content(schema = @Schema(implementation = UseItemResponse.class))), + @ApiResponse(responseCode = "400", description = "잘못된 요청 데이터"), + @ApiResponse(responseCode = "401", description = "인증 실패"), + @ApiResponse(responseCode = "404", description = "아이템 없음") + }) + @PostMapping("/me/items/use") + public ResponseEntity> useItem( + @UserId Long userId, + @Valid @RequestBody UseItemRequest request + ) { + UseItemResponse useItemResponse = useItemUsecase.useItem( + new UseItemCommand(userId, request.itemId(), request.quantity()) + ); + return ResponseEntity.ok().body( + SuccessResponse.of( + UserHttpResponseCode.USE_ITEM_SUCCESS, + useItemResponse + )); + } } diff --git a/src/main/java/org/runimo/runimo/user/controller/UserId.java b/src/main/java/org/runimo/runimo/user/controller/UserId.java index a1de34d0..219c98db 100644 --- a/src/main/java/org/runimo/runimo/user/controller/UserId.java +++ b/src/main/java/org/runimo/runimo/user/controller/UserId.java @@ -1,5 +1,7 @@ package org.runimo.runimo.user.controller; +import io.swagger.v3.oas.annotations.media.Schema; + import java.lang.annotation.ElementType; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; @@ -7,5 +9,6 @@ @Retention(RetentionPolicy.RUNTIME) @Target(ElementType.PARAMETER) +@Schema(description = "JWT 토큰 내 사용자 ID") public @interface UserId { } diff --git a/src/main/java/org/runimo/runimo/user/service/UserIdResolver.java b/src/main/java/org/runimo/runimo/user/controller/UserIdResolver.java similarity index 94% rename from src/main/java/org/runimo/runimo/user/service/UserIdResolver.java rename to src/main/java/org/runimo/runimo/user/controller/UserIdResolver.java index 5298e30d..580955a8 100644 --- a/src/main/java/org/runimo/runimo/user/service/UserIdResolver.java +++ b/src/main/java/org/runimo/runimo/user/controller/UserIdResolver.java @@ -1,8 +1,8 @@ -package org.runimo.runimo.user.service; +package org.runimo.runimo.user.controller; import lombok.RequiredArgsConstructor; -import org.runimo.runimo.user.controller.UserId; import org.runimo.runimo.user.domain.User; +import org.runimo.runimo.user.service.UserFinder; import org.springframework.core.MethodParameter; import org.springframework.security.core.Authentication; import org.springframework.security.core.context.SecurityContextHolder; diff --git a/src/main/java/org/runimo/runimo/user/controller/request/UseItemRequest.java b/src/main/java/org/runimo/runimo/user/controller/request/UseItemRequest.java new file mode 100644 index 00000000..7e5bcc92 --- /dev/null +++ b/src/main/java/org/runimo/runimo/user/controller/request/UseItemRequest.java @@ -0,0 +1,12 @@ +package org.runimo.runimo.user.controller.request; + +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "아이템 사용 요청 DTO") +public record UseItemRequest( + @Schema(description = "아이템 ID", example = "1") + Long itemId, + @Schema(description = "수량", example = "1") + Long quantity +) { +} diff --git a/src/main/java/org/runimo/runimo/user/domain/UserItem.java b/src/main/java/org/runimo/runimo/user/domain/UserItem.java new file mode 100644 index 00000000..9d77275f --- /dev/null +++ b/src/main/java/org/runimo/runimo/user/domain/UserItem.java @@ -0,0 +1,42 @@ +package org.runimo.runimo.user.domain; + +import jakarta.persistence.Entity; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.runimo.runimo.common.BaseEntity; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class UserItem extends BaseEntity { + private Long userId; + private Long itemId; + private Long quantity; + + @Builder + public UserItem(Long userId, Long itemId, Long quantity) { + this.userId = userId; + this.itemId = itemId; + this.quantity = quantity; + validateQuantity(quantity); + } + + public void useItem(Long quantity) { + updateQuantity(this.quantity - quantity); + } + + public void gainItem(Long quantity) { + updateQuantity(this.quantity + quantity); + } + + private void updateQuantity(Long quantity) { + validateQuantity(quantity); + this.quantity = quantity; + } + + private void validateQuantity(Long quantity) { + if(quantity < 0) throw new IllegalArgumentException("quantity must be greater than zero"); + } +} diff --git a/src/main/java/org/runimo/runimo/user/enums/UserHttpResponseCode.java b/src/main/java/org/runimo/runimo/user/enums/UserHttpResponseCode.java index 2981ed23..e3b6f374 100644 --- a/src/main/java/org/runimo/runimo/user/enums/UserHttpResponseCode.java +++ b/src/main/java/org/runimo/runimo/user/enums/UserHttpResponseCode.java @@ -8,6 +8,8 @@ public enum UserHttpResponseCode implements CustomResponseCode { SIGNUP_SUCCESS("USH2002", "회원가입 성공", "회원가입 성공"), LOGIN_SUCCESS("USH2003", "로그인 성공", "로그인 성공"), REFRESH_SUCCESS("USH2004", "토큰 재발급 성공", "토큰 재발급 성공"), + + USE_ITEM_SUCCESS("USH2005", "아이템 사용 성공", "아이템 사용 성공"), ; private final String code; diff --git a/src/main/java/org/runimo/runimo/user/repository/UserItemRepository.java b/src/main/java/org/runimo/runimo/user/repository/UserItemRepository.java new file mode 100644 index 00000000..90ea7483 --- /dev/null +++ b/src/main/java/org/runimo/runimo/user/repository/UserItemRepository.java @@ -0,0 +1,23 @@ +package org.runimo.runimo.user.repository; + +import jakarta.persistence.LockModeType; +import jakarta.persistence.QueryHint; +import org.runimo.runimo.user.domain.UserItem; +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.jpa.repository.QueryHints; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +@Repository +public interface UserItemRepository extends JpaRepository { + @Query("select ui from UserItem ui where ui.userId = :userId and ui.itemId = :itemId") + Optional findByUserIdAndItemId(Long userId, Long itemId); + + @Lock(LockModeType.PESSIMISTIC_WRITE) + @QueryHints({@QueryHint(name = "jakarta.persistence.lock.timeout", value = "3000")}) + @Query("select ui from UserItem ui where ui.userId = :userId and ui.itemId = :itemId") + Optional findByUserIdAndItemIdForUpdate(Long userId, Long itemId); +} diff --git a/src/main/java/org/runimo/runimo/user/service/UserCreator.java b/src/main/java/org/runimo/runimo/user/service/UserCreator.java new file mode 100644 index 00000000..9b8d9740 --- /dev/null +++ b/src/main/java/org/runimo/runimo/user/service/UserCreator.java @@ -0,0 +1,37 @@ +package org.runimo.runimo.user.service; + +import lombok.RequiredArgsConstructor; +import org.runimo.runimo.user.domain.OAuthInfo; +import org.runimo.runimo.user.domain.SocialProvider; +import org.runimo.runimo.user.domain.User; +import org.runimo.runimo.user.repository.OAuthInfoRepository; +import org.runimo.runimo.user.repository.UserRepository; +import org.runimo.runimo.user.service.dtos.UserSignupCommand; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +@Component +@RequiredArgsConstructor +public class UserCreator { + private final UserRepository userRepository; + private final OAuthInfoRepository oAuthInfoRepository; + + @Transactional + public User createUser(UserSignupCommand command) { + User user = User.builder() + .nickname(command.nickname()) + .imgUrl(command.imgUrl()) + .build(); + return userRepository.saveAndFlush(user); + } + + @Transactional + public OAuthInfo createUserOAuthInfo(User user, SocialProvider provider, String providerId) { + OAuthInfo oAuthInfo = OAuthInfo.builder() + .user(user) + .provider(provider) + .providerId(providerId) + .build(); + return oAuthInfoRepository.save(oAuthInfo); + } +} diff --git a/src/main/java/org/runimo/runimo/user/service/UserItemCreator.java b/src/main/java/org/runimo/runimo/user/service/UserItemCreator.java new file mode 100644 index 00000000..8204c53d --- /dev/null +++ b/src/main/java/org/runimo/runimo/user/service/UserItemCreator.java @@ -0,0 +1,36 @@ +package org.runimo.runimo.user.service; + +import lombok.RequiredArgsConstructor; +import org.runimo.runimo.item.repository.ItemRepository; +import org.runimo.runimo.user.domain.UserItem; +import org.runimo.runimo.user.repository.UserItemRepository; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Component +@RequiredArgsConstructor +public class UserItemCreator { + + private final UserItemRepository userItemRepository; + private final ItemRepository itemRepository; + + @Transactional + public UserItem create(UserItem userItem) { + return userItemRepository.save(userItem); + } + + /* + * 아이템ID, 유저ID의 모든 순서쌍을 저장한다. + * 회원가입 시 실행된다. + * */ + @Transactional + public void createAll(Long userId) { + List itemIds = itemRepository.findAllItemIds(); + userItemRepository.saveAll( + itemIds.stream() + .map(itemId -> new UserItem(userId, itemId, 0L)) + .toList()); + } +} diff --git a/src/main/java/org/runimo/runimo/user/service/UserItemFinder.java b/src/main/java/org/runimo/runimo/user/service/UserItemFinder.java new file mode 100644 index 00000000..bf4b753d --- /dev/null +++ b/src/main/java/org/runimo/runimo/user/service/UserItemFinder.java @@ -0,0 +1,25 @@ +package org.runimo.runimo.user.service; + +import lombok.RequiredArgsConstructor; +import org.runimo.runimo.user.domain.UserItem; +import org.runimo.runimo.user.repository.UserItemRepository; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Optional; + +@Component +@RequiredArgsConstructor +public class UserItemFinder { + + private final UserItemRepository userItemRepository; + + @Transactional(readOnly = true) + public Optional findByUserIdAndItemId(Long userId, Long itemId) { + return userItemRepository.findByUserIdAndItemId(userId,itemId); + } + + public Optional findByUserIdAndItemIdWithXLock(Long userId, Long itemId) { + return userItemRepository.findByUserIdAndItemIdForUpdate(userId,itemId); + } +} diff --git a/src/main/java/org/runimo/runimo/user/service/dtos/GainItemCommand.java b/src/main/java/org/runimo/runimo/user/service/dtos/GainItemCommand.java new file mode 100644 index 00000000..e65b3af6 --- /dev/null +++ b/src/main/java/org/runimo/runimo/user/service/dtos/GainItemCommand.java @@ -0,0 +1,8 @@ +package org.runimo.runimo.user.service.dtos; + +public record GainItemCommand( + Long userId, + Long itemId, + Long quantity +) { +} diff --git a/src/main/java/org/runimo/runimo/user/service/dtos/GainItemResponse.java b/src/main/java/org/runimo/runimo/user/service/dtos/GainItemResponse.java new file mode 100644 index 00000000..0da2c76f --- /dev/null +++ b/src/main/java/org/runimo/runimo/user/service/dtos/GainItemResponse.java @@ -0,0 +1,7 @@ +package org.runimo.runimo.user.service.dtos; + +public record GainItemResponse( + Long itemId, + Long quantity +) { +} diff --git a/src/main/java/org/runimo/runimo/user/service/dtos/UseItemCommand.java b/src/main/java/org/runimo/runimo/user/service/dtos/UseItemCommand.java new file mode 100644 index 00000000..18c425a2 --- /dev/null +++ b/src/main/java/org/runimo/runimo/user/service/dtos/UseItemCommand.java @@ -0,0 +1,15 @@ +package org.runimo.runimo.user.service.dtos; + +import org.runimo.runimo.item.domain.ActivityType; +import org.runimo.runimo.item.service.dtos.CreateActivityCommand; + +public record UseItemCommand( + Long userId, + Long itemId, + Long quantity +) { + + public static CreateActivityCommand toItemUseActivityCommand(UseItemCommand command) { + return new CreateActivityCommand(command.itemId(), command.userId(), command.quantity(), ActivityType.CONSUME); + } +} diff --git a/src/main/java/org/runimo/runimo/user/service/dtos/UseItemResponse.java b/src/main/java/org/runimo/runimo/user/service/dtos/UseItemResponse.java new file mode 100644 index 00000000..6061ccbf --- /dev/null +++ b/src/main/java/org/runimo/runimo/user/service/dtos/UseItemResponse.java @@ -0,0 +1,7 @@ +package org.runimo.runimo.user.service.dtos; + +public record UseItemResponse( + Long itemId, + Long quantity +) { +} diff --git a/src/main/java/org/runimo/runimo/user/service/usecases/GainItemUsecase.java b/src/main/java/org/runimo/runimo/user/service/usecases/GainItemUsecase.java new file mode 100644 index 00000000..09a060ce --- /dev/null +++ b/src/main/java/org/runimo/runimo/user/service/usecases/GainItemUsecase.java @@ -0,0 +1,8 @@ +package org.runimo.runimo.user.service.usecases; + +import org.runimo.runimo.user.service.dtos.GainItemCommand; +import org.runimo.runimo.user.service.dtos.GainItemResponse; + +public interface GainItemUsecase { + GainItemResponse gainItem(GainItemCommand command); +} diff --git a/src/main/java/org/runimo/runimo/user/service/usecases/GainItemUsecaseImpl.java b/src/main/java/org/runimo/runimo/user/service/usecases/GainItemUsecaseImpl.java new file mode 100644 index 00000000..ccd6a653 --- /dev/null +++ b/src/main/java/org/runimo/runimo/user/service/usecases/GainItemUsecaseImpl.java @@ -0,0 +1,33 @@ +package org.runimo.runimo.user.service.usecases; + +import lombok.RequiredArgsConstructor; +import org.runimo.runimo.user.domain.UserItem; +import org.runimo.runimo.user.repository.UserItemRepository; +import org.runimo.runimo.user.service.UserItemFinder; +import org.runimo.runimo.user.service.dtos.GainItemCommand; +import org.runimo.runimo.user.service.dtos.GainItemResponse; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.NoSuchElementException; + +@Service +@RequiredArgsConstructor +public class GainItemUsecaseImpl implements GainItemUsecase { + + private final UserItemFinder userItemFinder; + private final UserItemRepository userItemRepository; + + @Override + @Transactional + public GainItemResponse gainItem(GainItemCommand command) { + UserItem userItem = userItemFinder.findByUserIdAndItemIdWithXLock(command.userId(), command.itemId()) + .orElseThrow(NoSuchElementException::new); + userItem.gainItem(command.quantity()); + userItemRepository.save(userItem); + return new GainItemResponse( + userItem.getItemId(), + userItem.getQuantity() + ); + } +} diff --git a/src/main/java/org/runimo/runimo/user/service/usecases/UseItemUsecase.java b/src/main/java/org/runimo/runimo/user/service/usecases/UseItemUsecase.java new file mode 100644 index 00000000..f572b929 --- /dev/null +++ b/src/main/java/org/runimo/runimo/user/service/usecases/UseItemUsecase.java @@ -0,0 +1,9 @@ +package org.runimo.runimo.user.service.usecases; + +import org.runimo.runimo.user.service.dtos.UseItemCommand; +import org.runimo.runimo.user.service.dtos.UseItemResponse; + +public interface UseItemUsecase { + UseItemResponse useItem(UseItemCommand command); + +} diff --git a/src/main/java/org/runimo/runimo/user/service/usecases/UseItemUsecaseImpl.java b/src/main/java/org/runimo/runimo/user/service/usecases/UseItemUsecaseImpl.java new file mode 100644 index 00000000..c4913e88 --- /dev/null +++ b/src/main/java/org/runimo/runimo/user/service/usecases/UseItemUsecaseImpl.java @@ -0,0 +1,30 @@ +package org.runimo.runimo.user.service.usecases; + +import lombok.RequiredArgsConstructor; +import org.runimo.runimo.item.service.ItemActivityCreator; +import org.runimo.runimo.user.domain.UserItem; +import org.runimo.runimo.user.service.UserItemFinder; +import org.runimo.runimo.user.service.dtos.UseItemCommand; +import org.runimo.runimo.user.service.dtos.UseItemResponse; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.NoSuchElementException; + +@Service +@RequiredArgsConstructor +public class UseItemUsecaseImpl implements UseItemUsecase { + + private final UserItemFinder userItemFinder; + private final ItemActivityCreator itemActivityCreator; + + @Override + @Transactional + public UseItemResponse useItem(UseItemCommand command) { + UserItem userItem = userItemFinder.findByUserIdAndItemIdWithXLock(command.userId(), command.itemId()) + .orElseThrow(NoSuchElementException::new); + userItem.useItem(command.quantity()); + itemActivityCreator.createItemActivity(UseItemCommand.toItemUseActivityCommand(command)); + return new UseItemResponse(userItem.getItemId(), userItem.getQuantity()); + } +} diff --git a/src/main/java/org/runimo/runimo/user/service/UserOAuthService.java b/src/main/java/org/runimo/runimo/user/service/usecases/UserOAuthUsecaseImpl.java similarity index 75% rename from src/main/java/org/runimo/runimo/user/service/UserOAuthService.java rename to src/main/java/org/runimo/runimo/user/service/usecases/UserOAuthUsecaseImpl.java index 85e06105..748f50a6 100644 --- a/src/main/java/org/runimo/runimo/user/service/UserOAuthService.java +++ b/src/main/java/org/runimo/runimo/user/service/usecases/UserOAuthUsecaseImpl.java @@ -1,4 +1,4 @@ -package org.runimo.runimo.user.service; +package org.runimo.runimo.user.service.usecases; import com.auth0.jwt.JWT; import com.auth0.jwt.interfaces.DecodedJWT; @@ -11,23 +11,24 @@ import org.runimo.runimo.user.domain.SocialProvider; import org.runimo.runimo.user.domain.User; import org.runimo.runimo.user.repository.OAuthInfoRepository; -import org.runimo.runimo.user.repository.UserRepository; +import org.runimo.runimo.user.service.UserCreator; +import org.runimo.runimo.user.service.UserItemCreator; import org.runimo.runimo.user.service.dtos.SignupUserInfo; import org.runimo.runimo.user.service.dtos.TokenPair; import org.runimo.runimo.user.service.dtos.UserSignupCommand; -import org.runimo.runimo.user.service.usecases.UserOAuthUsecase; import org.springframework.stereotype.Service; import java.util.NoSuchElementException; @Service @RequiredArgsConstructor -public class UserOAuthService implements UserOAuthUsecase { +public class UserOAuthUsecaseImpl implements UserOAuthUsecase { private final JwtTokenFactory jwtfactory; private final OidcService oidcService; private final OidcNonceService oidcNonceService; + private final UserItemCreator userItemCreator; private final OAuthInfoRepository oAuthInfoRepository; - private final UserRepository userRepository; + private final UserCreator userCreator; @Override @Transactional @@ -50,17 +51,9 @@ public SignupUserInfo validateAndSignup(final UserSignupCommand command, final S throw new IllegalArgumentException(); }); - User user = User.builder() - .nickname(command.nickname()) - .imgUrl(command.imgUrl()) - .build(); - userRepository.saveAndFlush(user); - OAuthInfo oAuthInfo = new OAuthInfo( - user, - command.provider(), - pid - ); - oAuthInfoRepository.save(oAuthInfo); - return new SignupUserInfo(user.getId(), jwtfactory.generateTokenPair(user)); + User savedUser = userCreator.createUser(command); + userCreator.createUserOAuthInfo(savedUser, provider, pid); + userItemCreator.createAll(savedUser.getId()); + return new SignupUserInfo(savedUser.getId(), jwtfactory.generateTokenPair(savedUser)); } } diff --git a/src/test/java/org/runimo/runimo/user/UserItemFixtures.java b/src/test/java/org/runimo/runimo/user/UserItemFixtures.java new file mode 100644 index 00000000..fea460b0 --- /dev/null +++ b/src/test/java/org/runimo/runimo/user/UserItemFixtures.java @@ -0,0 +1,17 @@ +package org.runimo.runimo.user; + +import org.runimo.runimo.user.domain.UserItem; + +public class UserItemFixtures { + + private static final Long DEFAULT_USER_ID = 1L; + private static final Long DEFAULT_ITEM_ID = 1L; + + public static UserItem getUserItemWithQuantity(Long quantity) { + return UserItem.builder() + .userId(DEFAULT_USER_ID) + .itemId(DEFAULT_ITEM_ID) + .quantity(quantity) + .build(); + } +} diff --git a/src/test/java/org/runimo/runimo/user/domain/UserItemTest.java b/src/test/java/org/runimo/runimo/user/domain/UserItemTest.java new file mode 100644 index 00000000..77a94999 --- /dev/null +++ b/src/test/java/org/runimo/runimo/user/domain/UserItemTest.java @@ -0,0 +1,43 @@ +package org.runimo.runimo.user.domain; + + +import org.junit.jupiter.api.Test; +import org.runimo.runimo.user.UserItemFixtures; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + + +class UserItemTest { + + @Test + void 아이템_사용_수량감소_테스트() { + //given + UserItem userItem = UserItemFixtures.getUserItemWithQuantity(10L); + //when + userItem.useItem(10L); + //then + assertEquals(0L, userItem.getQuantity()); + } + + @Test + void 아이템_사용_보유_개수_초과사용시_에러_테스트() { + //given + UserItem userItem = UserItemFixtures.getUserItemWithQuantity(10L); + + //when + assertThrows(IllegalArgumentException.class, ()-> userItem.useItem(20L)); + assertEquals(10L, userItem.getQuantity()); + } + + @Test + void 아이템_획득_수량증가_테스트() { + //given + UserItem userItem = UserItemFixtures.getUserItemWithQuantity(10L); + //when + userItem.gainItem(10L); + //then + assertEquals(20L, userItem.getQuantity()); + } +} + diff --git a/src/test/java/org/runimo/runimo/user/service/usecases/GainItemUsecaseTest.java b/src/test/java/org/runimo/runimo/user/service/usecases/GainItemUsecaseTest.java new file mode 100644 index 00000000..64dcb850 --- /dev/null +++ b/src/test/java/org/runimo/runimo/user/service/usecases/GainItemUsecaseTest.java @@ -0,0 +1,49 @@ +package org.runimo.runimo.user.service.usecases; + +import org.junit.jupiter.api.Test; +import org.runimo.runimo.user.domain.UserItem; +import org.runimo.runimo.user.repository.UserItemRepository; +import org.runimo.runimo.user.service.dtos.GainItemCommand; +import org.runimo.runimo.user.service.dtos.GainItemResponse; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; + +import java.util.concurrent.CountDownLatch; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +@ActiveProfiles("test") +@SpringBootTest +class GainItemUsecaseTest { + + @Autowired + UserItemRepository userItemRepository; + @Autowired + private GainItemUsecase gainItemUsecase; + + @Test + void 아이템_획득_유즈케이스_동시성_테스트() throws InterruptedException { + userItemRepository.saveAndFlush(new UserItem(1L, 1L, 0L)); + GainItemCommand command = new GainItemCommand(1L, 1L, 10L); + int threadCount = 10; + CountDownLatch latch = new CountDownLatch(threadCount); + Runnable task = () -> { + try { + GainItemResponse response = gainItemUsecase.gainItem(command); + assertNotNull(response); + } finally { + latch.countDown(); + } + }; + for (int i = 0; i < threadCount; i++) { + new Thread(task).start(); + } + + latch.await(); + + UserItem ui = userItemRepository.findByUserIdAndItemId(1L, 1L).get(); + assertEquals(100L, ui.getQuantity()); + } +} diff --git a/src/test/java/org/runimo/runimo/user/service/usecases/UseItemUsecaseTest.java b/src/test/java/org/runimo/runimo/user/service/usecases/UseItemUsecaseTest.java new file mode 100644 index 00000000..790712ac --- /dev/null +++ b/src/test/java/org/runimo/runimo/user/service/usecases/UseItemUsecaseTest.java @@ -0,0 +1,48 @@ +package org.runimo.runimo.user.service.usecases; + +import org.junit.jupiter.api.Test; +import org.runimo.runimo.item.service.ItemActivityCreator; +import org.runimo.runimo.user.UserItemFixtures; +import org.runimo.runimo.user.service.UserItemFinder; +import org.runimo.runimo.user.service.dtos.UseItemCommand; +import org.runimo.runimo.user.service.dtos.UseItemResponse; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.bean.override.mockito.MockitoBean; + +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +@ActiveProfiles("test") +@SpringBootTest +class UseItemUsecaseTest { + + @MockitoBean + private UserItemFinder userItemFinder; + + @MockitoBean + private ItemActivityCreator itemActivityCreator; + + @Autowired + private UseItemUsecase useItemUsecase; + + @Test + void 아이템_사용_유즈케이스_테스트() { + //given + UseItemCommand command = new UseItemCommand(1L, 1L, 10L); + when(userItemFinder.findByUserIdAndItemIdWithXLock(any(), any())) + .thenReturn(Optional.ofNullable(UserItemFixtures.getUserItemWithQuantity(10L))); + //when + UseItemResponse res = useItemUsecase.useItem(command); + + //then + verify(itemActivityCreator, times(1)).createItemActivity(any()); + assertNotNull(res); + assertEquals(command.itemId(), res.itemId()); + assertEquals(0, res.quantity()); + } +} \ 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 00000000..3264ff41 --- /dev/null +++ b/src/test/resources/application-test.yml @@ -0,0 +1,56 @@ +server: + port: 8080 + tomcat: + threads: + max: 200 + accept-count: 100 + max-connections: 8912 + max-keep-alive-requests: 200 +spring: + config: + import: + - optional:file:${ENV_PATH:.}/.env.${SPRING_PROFILES_ACTIVE:test}[.properties] + datasource: + driver-class-name: org.h2.Driver + url: jdbc:h2:mem:testdb;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE + username: ${DB_USERNAME} + password: ${DB_PASSWORD} + jpa: + hibernate: + ddl-auto: ${JPA_DDL_AUTO:update} + properties: + hibernate: + dialect: org.hibernate.dialect.H2Dialect + open-in-view: false + jackson: + property-naming-strategy: SNAKE_CASE + security: + oauth2: + client: + registration: + kakao: + client-id: ${KAKAO_CLIENT_ID} + client-name: kakao + redirectUri: ${KAKAO_REDIRECT_URI} + authorization-grant-type: authorization_code + provider: + kakao: + authorization-uri: https://kauth.kakao.com/oauth/authorize + token-uri: https://kauth.kakao.com/oauth/token + user-info-uri: https://kapi.kakao.com/v1/oidc/userinfo +springdoc: + swagger-ui: + path: /swagger-ui.html +jwt: + secret: ${JWT_SECRET} + expiration: ${JWT_EXPIRATION:3600000} + refresh: + expiration: ${JWT_REFRESH_EXPIRATION:86400000} + +logging: + level: + org: + hibernate: + type: + descriptor: + sql: debug \ No newline at end of file