Skip to content
Merged
71 changes: 71 additions & 0 deletions src/main/java/org/runimo/runimo/user/controller/EggController.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
package org.runimo.runimo.user.controller;

import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.runimo.runimo.common.response.SuccessResponse;
import org.runimo.runimo.user.controller.request.RegisterEggRequest;
import org.runimo.runimo.user.controller.request.UseLovePointRequest;
import org.runimo.runimo.user.service.dtos.*;
import org.runimo.runimo.user.enums.UserHttpResponseCode;
import org.runimo.runimo.user.service.usecases.eggs.IncubatingEggQueryUsecase;
import org.runimo.runimo.user.service.usecases.eggs.EggRegisterUsecase;
import org.runimo.runimo.user.service.usecases.eggs.GiveLovePointToEggUsecase;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

import java.net.URI;

@Tag(name = "알 부화 API")
@RestController
@RequestMapping("/api/v1/users/eggs")
@RequiredArgsConstructor
public class EggController {

private final EggRegisterUsecase eggRegisterUsecase;
private final GiveLovePointToEggUsecase giveLovePointToEggUsecase;
private final IncubatingEggQueryUsecase incubatingEggQueryUsecase;

@PostMapping
public ResponseEntity<SuccessResponse<RegisterEggResponse>> registerEgg(
@UserId Long userId,
@Valid @RequestBody RegisterEggRequest request
) {
RegisterEggResponse registerEggResponse = eggRegisterUsecase.execute(
new RegisterEggCommand(userId, request.itemId())
);
return ResponseEntity.created(URI.create("/api/v1/users/eggs")).body(
SuccessResponse.of(
UserHttpResponseCode.REGISTER_EGG_SUCCESS,
registerEggResponse
));
}

@PatchMapping
public ResponseEntity<SuccessResponse<UseLovePointResponse>> useLovePoint(
@UserId Long userId,
@Valid @RequestBody UseLovePointRequest request
) {
UseLovePointResponse useLovePointResponse = giveLovePointToEggUsecase.execute(
new UseLovePointCommand(userId, request.itemId(), request.lovePointAmount())
);
return ResponseEntity.ok().body(
SuccessResponse.of(
UserHttpResponseCode.USE_LOVE_POINT_SUCCESS,
useLovePointResponse
));
}

@GetMapping
public ResponseEntity<SuccessResponse<QueryIncubatingEggResponse>> getEgg(
@UserId Long userId
) {
QueryIncubatingEggResponse response = incubatingEggQueryUsecase.execute(userId);
return ResponseEntity.ok().body(
SuccessResponse.of(
UserHttpResponseCode.MY_PAGE_DATA_FETCHED,
response
));
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.runimo.runimo.common.response.SuccessResponse;
import org.runimo.runimo.user.service.usecases.MainViewQueryUsecase;
import org.runimo.runimo.user.service.usecases.query.MainViewQueryUsecase;
import org.runimo.runimo.user.service.dtos.MainViewResponse;
import org.runimo.runimo.user.enums.UserHttpResponseCode;
import org.springframework.http.ResponseEntity;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
import org.runimo.runimo.user.enums.UserHttpResponseCode;
import org.runimo.runimo.user.service.dtos.AuthResponse;
import org.runimo.runimo.user.service.dtos.SignupUserResponse;
import org.runimo.runimo.user.service.usecases.UserOAuthUsecase;
import org.runimo.runimo.user.service.usecases.auth.UserOAuthUsecase;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,8 @@
import org.runimo.runimo.user.service.dtos.ItemQueryResponse;
import org.runimo.runimo.user.service.dtos.UseItemCommand;
import org.runimo.runimo.user.service.dtos.UseItemResponse;
import org.runimo.runimo.user.service.usecases.MyItemQueryUsecase;
import org.runimo.runimo.user.service.usecases.UseItemUsecase;
import org.runimo.runimo.user.service.usecases.query.MyItemQueryUsecase;
import org.runimo.runimo.user.service.usecases.items.UseItemUsecase;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package org.runimo.runimo.user.controller.request;

public record RegisterEggRequest(
Long itemId
) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package org.runimo.runimo.user.controller.request;

public record UseLovePointRequest(
Long itemId,
Long lovePointAmount
) {
}
8 changes: 8 additions & 0 deletions src/main/java/org/runimo/runimo/user/domain/EggStatus.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package org.runimo.runimo.user.domain;

public enum EggStatus {
WAITING, // 부화 대기
INCUBATING, // 부화 중
INCUBATED, // 부화 완료 대기
HATCHED // 부화 완료
}
90 changes: 90 additions & 0 deletions src/main/java/org/runimo/runimo/user/domain/IncubatingEgg.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
package org.runimo.runimo.user.domain;

import jakarta.persistence.*;
import java.util.Objects;
import lombok.AccessLevel;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import org.runimo.runimo.common.BaseEntity;

@Table(name = "incubating_eggs")
@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class IncubatingEgg extends BaseEntity {

@Column(name = "user_id", nullable = false)
private Long userId;

@Column(name = "egg_id", nullable = false)
private Long eggId;

@Column(name = "current_love_point_amount", nullable = false)
private Long currentLovePointAmount;

@Column(name = "hatch_require_amount", nullable = false)
private Long hatchRequireAmount;

@Column(name = "egg_status", nullable = false)
@Enumerated(EnumType.STRING)
private EggStatus status;

@Builder
public IncubatingEgg(Long userId, Long eggId, Long currentLovePointAmount, Long hatchRequireAmount, EggStatus status) {
validateCreation(status, currentLovePointAmount, hatchRequireAmount);
this.userId = userId;
this.eggId = eggId;
this.currentLovePointAmount = currentLovePointAmount;
this.hatchRequireAmount = hatchRequireAmount;
this.status = status;
}

public void startIncubation() {
validateStateTransition(EggStatus.WAITING, "부화 대기중인 알에만 부화 시작가능");
this.status = EggStatus.INCUBATING;
}

public void gainLovePoint(final Long amount) {
validateStateTransition(EggStatus.INCUBATING, "부화중인 알에만 애정 포인트 추가가능");
validateAmount(amount);
if (currentLovePointAmount + amount > hatchRequireAmount) {
throw new IllegalArgumentException("애정 포인트가 부화에 필요한 양을 초과했습니다. 현재: " + currentLovePointAmount + ", 추가량: " + amount + ", 필요량: " + hatchRequireAmount);
}
this.currentLovePointAmount += amount;
if (Objects.equals(this.currentLovePointAmount, hatchRequireAmount)) {
this.status = EggStatus.INCUBATED;
}
}

public void hatch() {
validateStateTransition(EggStatus.INCUBATED, "애정 포인트가 가득찬 알만 부화 가능");
this.status = EggStatus.HATCHED;
}

private void validateCreation(final EggStatus status, final Long currentLovePointAmount, final Long hatchRequireAmount) {
if(status == EggStatus.WAITING && currentLovePointAmount != 0) {
throw new IllegalArgumentException("부화 대기중인 알은 애정 포인트가 0이어야 합니다. 현재: " + currentLovePointAmount);
}
if(hatchRequireAmount <= 0) {
throw new IllegalArgumentException("부화에 필요한 애정 포인트는 0보다 커야 합니다. 현재: " + hatchRequireAmount);
}
}


private void validateStateTransition(final EggStatus expected, final String message) {
if (status != expected) {
throw new IllegalStateException(message + " 현재 상태: " + status);
}
}

private void validateAmount(final Long amount) {
if (amount == null || amount <= 0) {
throw new IllegalArgumentException("애정 포인트는 0보다 커야 합니다. 현재: " + amount);
}
}

public boolean isReadyToHatch() {
return this.status == EggStatus.INCUBATED && currentLovePointAmount.equals(hatchRequireAmount);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@ public enum UserHttpResponseCode implements CustomResponseCode {
REFRESH_SUCCESS("USH2004", "토큰 재발급 성공", "토큰 재발급 성공"),

USE_ITEM_SUCCESS("USH2005", "아이템 사용 성공", "아이템 사용 성공"),
;
REGISTER_EGG_SUCCESS("USH2006", "부화기 등록 성공", "부화기 등록 성공"),
USE_LOVE_POINT_SUCCESS("USH2007","애정 사용 성공" , "애정 사용 성공");

private final String code;
private final String clientMessage;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
package org.runimo.runimo.user.repository;

import jakarta.persistence.LockModeType;
import jakarta.persistence.QueryHint;
import org.runimo.runimo.user.domain.IncubatingEgg;
import org.runimo.runimo.user.service.dtos.IncubatingEggView;
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.List;
import java.util.Optional;

@Repository
public interface IncubatingEggRepository extends JpaRepository<IncubatingEgg, Integer> {

@Lock(LockModeType.PESSIMISTIC_WRITE)
@QueryHints({@QueryHint(name = "jakarta.persistence.lock.timeout", value = "3000")})
@Query("select ie from IncubatingEgg ie where ie.userId = :userId and ie.id = :eggId")
Optional<IncubatingEgg> findByUserIdAndEggIdForUpdate(Long userId, Long eggId);

@Query("select ie from IncubatingEgg ie where ie.userId = :userId and (ie.status = 'INCUBATING' or ie.status = 'INCUBATED')")
List<IncubatingEgg> findAllByUserId(Long userId);

@Query("select new org.runimo.runimo.user.service.dtos.IncubatingEggView(ie.id, e.name, e.imgUrl, ie.hatchRequireAmount, ie.currentLovePointAmount, ie.status) " +
"from IncubatingEgg ie " +
"join Egg e on e.id = ie.eggId " +
"where ie.userId = :userId and (ie.status = 'INCUBATING' or ie.status = 'INCUBATED')")
List<IncubatingEggView> findAllViewByUserId(Long userId);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
package org.runimo.runimo.user.service;

import lombok.RequiredArgsConstructor;
import org.runimo.runimo.user.domain.IncubatingEgg;
import org.runimo.runimo.user.repository.IncubatingEggRepository;
import org.runimo.runimo.user.service.dtos.IncubatingEggView;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;

import java.util.List;

@Component
@RequiredArgsConstructor
public class IncubatingEggFinder {

private final IncubatingEggRepository incubatingEggRepository;

@Transactional(readOnly = true)
public List<IncubatingEgg> findIncubatingEggsByUserId(Long userId) {
return incubatingEggRepository.findAllByUserId(userId);
}

@Transactional(readOnly = true)
public List<IncubatingEggView> findIncubatingEggsViewByUserId(Long userId) {
return incubatingEggRepository.findAllViewByUserId(userId);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
package org.runimo.runimo.user.service;

import lombok.RequiredArgsConstructor;
import org.runimo.runimo.item.domain.Egg;
import org.runimo.runimo.user.domain.IncubatingEgg;
import org.runimo.runimo.user.repository.IncubatingEggRepository;
import org.runimo.runimo.user.service.dtos.UseLovePointCommand;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;

@Component
@RequiredArgsConstructor
public class IncubatingEggProcessor {

private final IncubatingEggRepository incubatingEggRepository;

@Transactional
public IncubatingEgg create(Long userId, Egg egg) {
IncubatingEgg incubatingEgg = IncubatingEgg.builder()
.userId(userId)
.eggId(egg.getId())
.currentLovePointAmount(0L)
.hatchRequireAmount(egg.getHatchRequireAmount())
.build();
return incubatingEggRepository.save(incubatingEgg);
}

@Transactional
public IncubatingEgg giveLovePoint(UseLovePointCommand useLovePointCommand) {
IncubatingEgg incubatingEgg = incubatingEggRepository.findByUserIdAndEggIdForUpdate(
useLovePointCommand.userId(),
useLovePointCommand.incubatingEggId())
.orElseThrow(() -> new IllegalArgumentException("Incubating egg not found"));
incubatingEgg.gainLovePoint(useLovePointCommand.lovePoint());
return incubatingEgg;
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import org.runimo.runimo.user.domain.LovePoint;
import org.runimo.runimo.user.repository.LovePointRepository;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;

@Component
public class LovePointProcessor {
Expand All @@ -13,7 +14,11 @@ public LovePointProcessor(LovePointRepository lovePointRepository) {
this.lovePointRepository = lovePointRepository;
}

// 유저의 러브포인트를 업데이트한다. XLOCK을 걸어서 동시성 문제를 해결한다.
/**
* [Warning] : LovePoint를 수정할때는, 항상 마지막 순서로 업데이트한다. (DEADLOCK 방지)
* 유저의 러브포인트를 업데이트한다. XLOCK을 걸어서 동시성 문제를 해결한다.
* */
@Transactional
public LovePoint updateLovePoint(Long userId, Long loveAmount) {
LovePoint lp = lovePointRepository.findByUserIdWithXLock(userId)
.orElseThrow(IllegalStateException::new);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
package org.runimo.runimo.user.service;

import lombok.RequiredArgsConstructor;
import org.runimo.runimo.item.domain.ActivityType;
import org.runimo.runimo.item.service.ItemActivityCreator;
import org.runimo.runimo.item.service.dtos.CreateActivityCommand;
import org.runimo.runimo.user.domain.UserItem;
import org.runimo.runimo.user.repository.UserItemRepository;
import org.springframework.stereotype.Component;
Expand All @@ -12,12 +15,23 @@ public class UserItemProcessor {

private final UserItemFinder userItemFinder;
private final UserItemRepository userItemRepository;
private final ItemActivityCreator itemActivityCreator;

@Transactional
public void updateItemQuantity(Long userId, Long itemId, Long amount) {
UserItem userItem = userItemFinder.findByUserIdAndItemIdWithXLock(userId, itemId)
.orElseThrow(IllegalStateException::new);
userItem.gainItem(amount);
itemActivityCreator.createItemActivity(new CreateActivityCommand(itemId, userId, amount, ActivityType.CONSUME));
userItemRepository.save(userItem);
}

@Transactional
public void useItem(Long userId, Long itemId, Long amount) {
UserItem userItem = userItemFinder.findByUserIdAndItemIdWithXLock(userId, itemId)
.orElseThrow(IllegalStateException::new);
userItem.useItem(amount);
itemActivityCreator.createItemActivity(new CreateActivityCommand(itemId, userId, amount, ActivityType.CONSUME));
userItemRepository.save(userItem);
}
}
Loading