From 1dc518a9c46848a272ddc255878207edd3179272 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=84=9C=EC=A4=80?= <104981505+xeoxxn@users.noreply.github.com> Date: Mon, 28 Sep 2026 07:41:43 +0900 Subject: [PATCH 1/6] =?UTF-8?q?feat:=20=EB=B9=8C=EB=A6=B4=EA=B2=8C=20?= =?UTF-8?q?=EB=8C=80=EC=97=AC=20=EC=8B=A0=EC=B2=AD=20API=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/app-api/build.gradle.kts | 2 + .../api/app/welfare/rental/AppRentalApi.java | 16 ++++++ .../welfare/rental/AppRentalController.java | 22 ++++++++ .../rental/request/RentalApplyRequest.java | 28 ++++++++++ .../rental/usecase/RentalApplyUseCase.java | 51 +++++++++++++++++++ .../domain/fee/service/PayerService.java | 3 ++ .../fee/service/impl/PayerServiceImpl.java | 6 +++ .../welfare/domain/rental/domain/Item.java | 9 ++++ .../domain/rental/domain/RentalErrorCode.java | 8 ++- .../domain/rental/domain/RentalHistory.java | 13 +++++ .../rental/repository/ItemRepository.java | 6 +++ .../repository/RentalHistoryRepository.java | 5 ++ .../rental/service/RentalTimeValidator.java | 27 ++++++++++ .../stream/db/welfare/ItemJpaRepository.java | 9 ++++ .../stream/db/welfare/ItemRepositoryImpl.java | 11 ++++ .../welfare/RentalHistoryJpaRepository.java | 2 + .../welfare/RentalHistoryRepositoryImpl.java | 10 ++++ 17 files changed, 227 insertions(+), 1 deletion(-) create mode 100644 api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/rental/request/RentalApplyRequest.java create mode 100644 api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/rental/usecase/RentalApplyUseCase.java create mode 100644 core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/rental/service/RentalTimeValidator.java diff --git a/api/app-api/build.gradle.kts b/api/app-api/build.gradle.kts index 73240a99..3c1ca940 100644 --- a/api/app-api/build.gradle.kts +++ b/api/app-api/build.gradle.kts @@ -16,6 +16,8 @@ dependencies { implementation(platform(libs.springBootDependenciesBom)) implementation(libs.springBootStarterWebmvc) implementation(libs.springBootStarterValidation) + implementation(libs.springBootStarterAspectj) + implementation(libs.springTx) implementation(libs.springdocStarterWebmvcUi) testImplementation(platform(libs.springBootDependenciesBom)) diff --git a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/rental/AppRentalApi.java b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/rental/AppRentalApi.java index f5b828c6..c2cd9d30 100644 --- a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/rental/AppRentalApi.java +++ b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/rental/AppRentalApi.java @@ -4,6 +4,7 @@ import io.swagger.v3.oas.annotations.tags.Tag; import kr.ac.kookmin.stream.api.app.AppApiUser; import kr.ac.kookmin.stream.api.app.welfare.rental.request.ItemListParams; +import kr.ac.kookmin.stream.api.app.welfare.rental.request.RentalApplyRequest; import kr.ac.kookmin.stream.api.app.welfare.rental.request.RentalHistoryListParams; import kr.ac.kookmin.stream.api.app.welfare.rental.response.ItemListItemResponse; import kr.ac.kookmin.stream.api.app.welfare.rental.response.RentalHistoryListResponse; @@ -46,4 +47,19 @@ ApiResponse getHistories( description = "지금 반납해야 하는 대여(대여 중 상태)와 반납 기한(dueAt)을 조회한다. " + "반납 기한은 대여일에 최대 대여 일수를 더한 날의 반납 마감 시각이다. 없으면 빈 배열이다.") ApiResponse getReturnRequired(AppApiUser apiUser); + + /** 대여 신청. 회비 납부·재고·중복 대여·대여 가능 시간을 검증한 뒤 이력을 만든다. */ + @Operation(summary = "대여 신청", + description = "물품을 대여 신청한다. 대여 시간은 영업시간(10~17시) 안이어야 하고 점심시간(12~13시)은 제외된다. " + + "이미 대여 중인 같은 물품이 있으면 거부하되, ignoreDuplicate=true면 건너뛴다. " + + "소모품은 신청 즉시 반납 완료로, 대여품은 대여 중으로 등록된다.") + @ApiErrorCode(type = CommonErrorCode.class, codes = {"INVALID_INPUT"}) + @ApiErrorCode( + type = RentalErrorCode.class, + codes = { + "ITEM_NOT_FOUND", "MEMBER_IS_NOT_PAYER", "ITEM_OUT_OF_STOCK", "RENTAL_ITEM_DUPLICATED", + "INVALID_RENTAL_TIME_RANGE", "INVALID_RENTAL_TIME_LUNCH_BREAK" + } + ) + ApiResponse applyRental(AppApiUser apiUser, RentalApplyRequest request); } diff --git a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/rental/AppRentalController.java b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/rental/AppRentalController.java index 974387ba..108e600d 100644 --- a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/rental/AppRentalController.java +++ b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/rental/AppRentalController.java @@ -3,19 +3,24 @@ import jakarta.validation.Valid; import kr.ac.kookmin.stream.api.app.AppApiUser; import kr.ac.kookmin.stream.api.app.welfare.rental.request.ItemListParams; +import kr.ac.kookmin.stream.api.app.welfare.rental.request.RentalApplyRequest; import kr.ac.kookmin.stream.api.app.welfare.rental.request.RentalHistoryListParams; import kr.ac.kookmin.stream.api.app.welfare.rental.response.ItemListItemResponse; import kr.ac.kookmin.stream.api.app.welfare.rental.response.RentalHistoryListResponse; import kr.ac.kookmin.stream.api.app.welfare.rental.response.ReturnRequiredListResponse; +import kr.ac.kookmin.stream.api.app.welfare.rental.usecase.RentalApplyUseCase; import kr.ac.kookmin.stream.api.common.dto.ApiResponse; import kr.ac.kookmin.stream.api.common.dto.CursorSliceResponse; import kr.ac.kookmin.stream.common.CursorSliceResult; import kr.ac.kookmin.stream.welfare.domain.rental.domain.Item; import kr.ac.kookmin.stream.welfare.domain.rental.service.ItemService; import kr.ac.kookmin.stream.welfare.domain.rental.service.RentalHistoryService; +import kr.ac.kookmin.stream.welfare.domain.rental.service.RentalTimeValidator; import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.ModelAttribute; +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.RestController; @@ -26,6 +31,7 @@ public class AppRentalController implements AppRentalApi { private final ItemService itemService; private final RentalHistoryService rentalHistoryService; + private final RentalApplyUseCase rentalApplyUseCase; @Override @GetMapping("/items") @@ -56,4 +62,20 @@ public ApiResponse getReturnRequired(AppApiUser apiU rentalHistoryService.getReturnRequiredRentals(apiUser.userId()) )); } + + @Override + @PostMapping("/histories") + public ApiResponse applyRental(AppApiUser apiUser, @Valid @RequestBody RentalApplyRequest request) { + // DB에 안 닿는 순수 검증은 트랜잭션(UseCase) 진입 전에 끝낸다 — 잘못된 요청이 커넥션을 잡지 않게 한다 + RentalTimeValidator.validate(request.rentAtHour(), request.rentAtMinute()); + rentalApplyUseCase.apply( + apiUser.userId(), + request.itemId(), + request.count(), + request.rentAtHour(), + request.rentAtMinute(), + request.ignoreDuplicate() + ); + return ApiResponse.success(); + } } diff --git a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/rental/request/RentalApplyRequest.java b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/rental/request/RentalApplyRequest.java new file mode 100644 index 00000000..bb9fe1b1 --- /dev/null +++ b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/rental/request/RentalApplyRequest.java @@ -0,0 +1,28 @@ +package kr.ac.kookmin.stream.api.app.welfare.rental.request; + +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Positive; + +public record RentalApplyRequest( + @NotNull(message = "대여할 물품을 선택해 주세요.") + Long itemId, + + @NotNull(message = "대여 수량을 입력해 주세요.") + @Positive(message = "대여 수량은 1개 이상이어야 합니다.") + Integer count, + + @NotNull(message = "대여 시작 시(hour)를 입력해 주세요.") + @Min(value = 0, message = "대여 시작 시(hour)는 0~23 사이여야 합니다.") + @Max(value = 23, message = "대여 시작 시(hour)는 0~23 사이여야 합니다.") + Integer rentAtHour, + + @NotNull(message = "대여 시작 분(minute)을 입력해 주세요.") + @Min(value = 0, message = "대여 시작 분(minute)은 0~59 사이여야 합니다.") + @Max(value = 59, message = "대여 시작 분(minute)은 0~59 사이여야 합니다.") + Integer rentAtMinute, + + @NotNull(message = "중복 대여 진행 여부를 입력해 주세요.") + Boolean ignoreDuplicate +) {} diff --git a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/rental/usecase/RentalApplyUseCase.java b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/rental/usecase/RentalApplyUseCase.java new file mode 100644 index 00000000..430b7118 --- /dev/null +++ b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/rental/usecase/RentalApplyUseCase.java @@ -0,0 +1,51 @@ +package kr.ac.kookmin.stream.api.app.welfare.rental.usecase; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.ZoneId; +import kr.ac.kookmin.stream.common.BusinessException; +import kr.ac.kookmin.stream.welfare.domain.fee.service.PayerService; +import kr.ac.kookmin.stream.welfare.domain.rental.domain.Item; +import kr.ac.kookmin.stream.welfare.domain.rental.domain.RentalErrorCode; +import kr.ac.kookmin.stream.welfare.domain.rental.domain.RentalHistory; +import kr.ac.kookmin.stream.welfare.domain.rental.repository.ItemRepository; +import kr.ac.kookmin.stream.welfare.domain.rental.repository.RentalHistoryRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +/** + * 대여 신청. {@code fee}(회비 납부 확인)와 {@code rental}(재고 차감, 이력 생성) 두 도메인을 조합한다. + * 대여 시간(영업시간·점심시간) 검증은 DB에 닿지 않으므로 이 트랜잭션에 들어오기 전, 컨트롤러에서 끝낸다. + */ +@Component +@RequiredArgsConstructor +public class RentalApplyUseCase { + + private static final ZoneId KST = ZoneId.of("Asia/Seoul"); + + private final PayerService payerService; + private final ItemRepository itemRepository; + private final RentalHistoryRepository rentalHistoryRepository; + + @Transactional + public void apply(Long memberId, Long itemId, int count, int rentAtHour, int rentAtMinute, boolean ignoreDuplicate) { + if (!payerService.isPayer(memberId)) { + throw new BusinessException(RentalErrorCode.MEMBER_IS_NOT_PAYER); + } + + if (!ignoreDuplicate && rentalHistoryRepository.existsActiveRental(itemId, memberId)) { + throw new BusinessException(RentalErrorCode.RENTAL_ITEM_DUPLICATED); + } + + // 비관적 락으로 읽어서, 동시에 들어온 다른 신청이 이 물품의 재고를 같이 통과하지 못하게 한다 + Item item = itemRepository.findByIdForUpdate(itemId) + .orElseThrow(() -> new BusinessException(RentalErrorCode.ITEM_NOT_FOUND)); + item.decreaseStock(count); + itemRepository.save(item); + + LocalDateTime now = LocalDateTime.now(KST); + LocalDateTime rentAt = LocalDate.now(KST).atTime(rentAtHour, rentAtMinute); + rentalHistoryRepository.save(RentalHistory.create(item, memberId, count, now, rentAt)); + } +} diff --git a/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/fee/service/PayerService.java b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/fee/service/PayerService.java index f988a3e6..585dbadb 100644 --- a/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/fee/service/PayerService.java +++ b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/fee/service/PayerService.java @@ -6,4 +6,7 @@ public interface PayerService { // 확인요청 처리 결과를 납부자 명부(Payer)에 반영한다. PAID면 등록, 그 외(UNPAID)면 명부에서 제거한다. void sync(Long memberId, String name, String studentId, TransferStatus status); + + // 회원이 납부자 명부에 등록돼 있는지 확인한다(회비 납부 여부가 필요한 다른 도메인의 조건 확인용). + boolean isPayer(Long memberId); } diff --git a/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/fee/service/impl/PayerServiceImpl.java b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/fee/service/impl/PayerServiceImpl.java index 5ba1fb3f..7b0d8404 100644 --- a/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/fee/service/impl/PayerServiceImpl.java +++ b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/fee/service/impl/PayerServiceImpl.java @@ -36,4 +36,10 @@ public void sync(Long memberId, String name, String studentId, TransferStatus st private String enrollmentYearOf(String studentId) { return studentId.substring(0, 4); } + + // 조회 쿼리가 1개라 트랜잭션을 걸지 않는다(PR#67/#69 원칙 — 조회니까 붙이는 게 아니라 실제 이득이 있을 때만 붙인다). + @Override + public boolean isPayer(Long memberId) { + return payerRepository.findByMemberId(memberId).isPresent(); + } } diff --git a/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/rental/domain/Item.java b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/rental/domain/Item.java index a10c5342..81923ab2 100644 --- a/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/rental/domain/Item.java +++ b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/rental/domain/Item.java @@ -1,5 +1,6 @@ package kr.ac.kookmin.stream.welfare.domain.rental.domain; +import kr.ac.kookmin.stream.common.BusinessException; import lombok.AccessLevel; import lombok.AllArgsConstructor; import lombok.EqualsAndHashCode; @@ -32,4 +33,12 @@ public static Item of( ReturnPolicy applicablePolicy = type == ItemType.RENTAL ? returnPolicy : null; return new Item(id, name, category, type, count, imageKey, applicablePolicy); } + + /** 재고를 차감한다. 재고가 부족하면(0 밑으로 내려가면) 예외를 던진다. */ + public void decreaseStock(int amount) { + if (amount > this.count) { + throw new BusinessException(RentalErrorCode.ITEM_OUT_OF_STOCK); + } + this.count -= amount; + } } diff --git a/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/rental/domain/RentalErrorCode.java b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/rental/domain/RentalErrorCode.java index 23b0ace6..f74647b1 100644 --- a/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/rental/domain/RentalErrorCode.java +++ b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/rental/domain/RentalErrorCode.java @@ -11,7 +11,13 @@ @AllArgsConstructor public enum RentalErrorCode implements ErrorCode { - ITEM_INVALID_CURSOR(ErrorStatus.BAD_REQUEST, "유효하지 않은 커서입니다."); + ITEM_INVALID_CURSOR(ErrorStatus.BAD_REQUEST, "유효하지 않은 커서입니다."), + ITEM_NOT_FOUND(ErrorStatus.NOT_FOUND, "존재하지 않는 물품입니다."), + MEMBER_IS_NOT_PAYER(ErrorStatus.BAD_REQUEST, "회비를 납부한 회원만 이용할 수 있습니다."), + ITEM_OUT_OF_STOCK(ErrorStatus.BAD_REQUEST, "재고가 부족합니다."), + RENTAL_ITEM_DUPLICATED(ErrorStatus.BAD_REQUEST, "이미 대여 중인 물품입니다."), + INVALID_RENTAL_TIME_RANGE(ErrorStatus.BAD_REQUEST, "대여 가능 시간이 아니에요. 10시-17시 사이로 선택해 주세요."), + INVALID_RENTAL_TIME_LUNCH_BREAK(ErrorStatus.BAD_REQUEST, "점심시간(12시-13시)에는 대여가 불가능해요."); private final int status; private final String message; diff --git a/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/rental/domain/RentalHistory.java b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/rental/domain/RentalHistory.java index f135e33f..962dac9d 100644 --- a/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/rental/domain/RentalHistory.java +++ b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/rental/domain/RentalHistory.java @@ -22,6 +22,19 @@ public class RentalHistory { private LocalDateTime rentAt; private LocalDateTime returnedAt; + /** + * 대여 신청으로 새 이력을 만든다. 소모품(반납 개념이 없는 물품)은 처음부터 반납 완료로, + * 대여품은 대여 중으로 시작한다 — 이 프로젝트엔 운영진이 수령을 확정해 주는 단계가 없어서, + * 레거시(billilge/backend) {@code RentalService.updateRentalStatus()}가 물품 인도 시점에 하던 + * "소모품이면 RENTAL 대신 RETURNED" 분기를 신청 시점으로 그대로 옮겼다. + */ + public static RentalHistory create(Item item, Long memberId, int rentedCount, LocalDateTime appliedAt, LocalDateTime rentAt) { + boolean returnable = item.getType() == ItemType.RENTAL; + RentalStatus status = returnable ? RentalStatus.RENTAL : RentalStatus.RETURNED; + LocalDateTime returnedAt = returnable ? null : appliedAt; + return new RentalHistory(null, item.getId(), memberId, null, null, status, rentedCount, appliedAt, rentAt, returnedAt); + } + public static RentalHistory of( Long id, Long itemId, diff --git a/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/rental/repository/ItemRepository.java b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/rental/repository/ItemRepository.java index 9747cf48..08e7e93b 100644 --- a/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/rental/repository/ItemRepository.java +++ b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/rental/repository/ItemRepository.java @@ -2,6 +2,7 @@ import java.util.Collection; import java.util.List; +import java.util.Optional; import kr.ac.kookmin.stream.common.CursorSliceResult; import kr.ac.kookmin.stream.welfare.domain.rental.domain.Item; import kr.ac.kookmin.stream.welfare.domain.rental.domain.ItemCategory; @@ -19,4 +20,9 @@ public interface ItemRepository { CursorSliceResult findSlice(ItemCategory category, String keyword, ItemCursor cursor, int size); List findAllByIds(Collection ids); + + /** 재고 차감용 비관적 락 조회. 동시에 들어온 대여 신청이 같은 물품의 재고를 동시에 읽지 못하게 한다. */ + Optional findByIdForUpdate(Long id); + + Item save(Item item); } diff --git a/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/rental/repository/RentalHistoryRepository.java b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/rental/repository/RentalHistoryRepository.java index 33721bb9..b541f86b 100644 --- a/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/rental/repository/RentalHistoryRepository.java +++ b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/rental/repository/RentalHistoryRepository.java @@ -12,4 +12,9 @@ public interface RentalHistoryRepository { * @param status 대여 상태. null이면 전체 */ List findAllByMemberId(Long memberId, RentalStatus status); + + RentalHistory save(RentalHistory history); + + /** 같은 회원이 같은 물품을 이미 대여 중인지(상태가 RENTAL인 이력이 있는지) 확인한다. */ + boolean existsActiveRental(Long itemId, Long memberId); } diff --git a/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/rental/service/RentalTimeValidator.java b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/rental/service/RentalTimeValidator.java new file mode 100644 index 00000000..0ae3fc93 --- /dev/null +++ b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/rental/service/RentalTimeValidator.java @@ -0,0 +1,27 @@ +package kr.ac.kookmin.stream.welfare.domain.rental.service; + +import java.time.LocalTime; +import kr.ac.kookmin.stream.common.BusinessException; +import kr.ac.kookmin.stream.welfare.domain.rental.domain.RentalErrorCode; + +// 대여 신청 시각(영업시간 10~17시, 점심시간 12~13시 제외)을 검증한다. +// fee 도메인의 FeeAmountCalculator/TossTransferLinkGenerator와 같은 패턴 — 상태 없는 순수 검증 로직은 정적 유틸로 둔다. +public final class RentalTimeValidator { + + private static final LocalTime OPEN = LocalTime.of(10, 0); + private static final LocalTime CLOSE = LocalTime.of(17, 0); + private static final LocalTime LUNCH_START = LocalTime.of(12, 0); + private static final LocalTime LUNCH_END = LocalTime.of(13, 0); + + private RentalTimeValidator() {} + + public static void validate(int hour, int minute) { + LocalTime rentalTime = LocalTime.of(hour, minute); + if (rentalTime.isBefore(OPEN) || rentalTime.isAfter(CLOSE)) { + throw new BusinessException(RentalErrorCode.INVALID_RENTAL_TIME_RANGE); + } + if (!rentalTime.isBefore(LUNCH_START) && rentalTime.isBefore(LUNCH_END)) { + throw new BusinessException(RentalErrorCode.INVALID_RENTAL_TIME_LUNCH_BREAK); + } + } +} diff --git a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/welfare/ItemJpaRepository.java b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/welfare/ItemJpaRepository.java index f1432b8f..e9fd3c2c 100644 --- a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/welfare/ItemJpaRepository.java +++ b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/welfare/ItemJpaRepository.java @@ -1,14 +1,23 @@ package kr.ac.kookmin.stream.db.welfare; +import jakarta.persistence.LockModeType; import java.util.List; +import java.util.Optional; import kr.ac.kookmin.stream.welfare.domain.rental.domain.ItemCategory; import org.springframework.data.domain.Pageable; 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; public interface ItemJpaRepository extends JpaRepository { + // 대여 신청의 재고 차감용. 비관적 락으로 읽어서, 같은 물품에 동시에 들어온 신청이 서로 다른 + // 트랜잭션의 커밋을 기다리게 한다(그냥 SELECT는 락이 없어 두 요청이 같은 재고를 동시에 통과할 수 있다). + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("SELECT i FROM ItemJpaEntity i WHERE i.id = :id") + Optional findByIdForUpdate(@Param("id") Long id); + // keywordPattern은 LIKE 패턴이다. 검색어의 %·_를 이스케이프한 뒤 '!'를 이스케이프 문자로 쓴다 // ('\\'는 MySQL 문자열 리터럴에서 다시 이스케이프되어 쓸 수 없다) @Query(""" diff --git a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/welfare/ItemRepositoryImpl.java b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/welfare/ItemRepositoryImpl.java index 3d130e47..bd423874 100644 --- a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/welfare/ItemRepositoryImpl.java +++ b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/welfare/ItemRepositoryImpl.java @@ -2,6 +2,7 @@ import java.util.Collection; import java.util.List; +import java.util.Optional; import kr.ac.kookmin.stream.common.CursorSliceResult; import kr.ac.kookmin.stream.welfare.domain.rental.domain.Item; import kr.ac.kookmin.stream.welfare.domain.rental.domain.ItemCategory; @@ -39,6 +40,16 @@ public List findAllByIds(Collection ids) { .toList(); } + @Override + public Optional findByIdForUpdate(Long id) { + return itemJpaRepository.findByIdForUpdate(id).map(ItemJpaEntity::toDomain); + } + + @Override + public Item save(Item item) { + return itemJpaRepository.save(ItemJpaEntity.from(item)).toDomain(); + } + // 검색어를 "포함" 조건의 LIKE 패턴으로 바꾼다. 검색어 안의 와일드카드가 패턴으로 해석되지 않게 이스케이프한다 private String toContainsPattern(String keyword) { String escaped = keyword diff --git a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/welfare/RentalHistoryJpaRepository.java b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/welfare/RentalHistoryJpaRepository.java index 4d3606e5..bee7b696 100644 --- a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/welfare/RentalHistoryJpaRepository.java +++ b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/welfare/RentalHistoryJpaRepository.java @@ -18,4 +18,6 @@ List findAllByMemberId( @Param("memberId") Long memberId, @Param("status") RentalStatus status ); + + boolean existsByItemIdAndMemberIdAndRentalStatus(Long itemId, Long memberId, RentalStatus rentalStatus); } diff --git a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/welfare/RentalHistoryRepositoryImpl.java b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/welfare/RentalHistoryRepositoryImpl.java index 8dfe1494..5b66dcfd 100644 --- a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/welfare/RentalHistoryRepositoryImpl.java +++ b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/welfare/RentalHistoryRepositoryImpl.java @@ -19,4 +19,14 @@ public List findAllByMemberId(Long memberId, RentalStatus status) .map(RentalHistoryJpaEntity::toDomain) .toList(); } + + @Override + public RentalHistory save(RentalHistory history) { + return rentalHistoryJpaRepository.save(RentalHistoryJpaEntity.from(history)).toDomain(); + } + + @Override + public boolean existsActiveRental(Long itemId, Long memberId) { + return rentalHistoryJpaRepository.existsByItemIdAndMemberIdAndRentalStatus(itemId, memberId, RentalStatus.RENTAL); + } } From aa5b2b72c6ef9b7cd65305022d0d167d79440734 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=84=9C=EC=A4=80?= <104981505+xeoxxn@users.noreply.github.com> Date: Mon, 28 Sep 2026 07:44:27 +0900 Subject: [PATCH 2/6] =?UTF-8?q?feat:=20=EB=B9=8C=EB=A6=B4=EA=B2=8C=20?= =?UTF-8?q?=EB=B0=98=EB=82=A9=20=EC=8B=A0=EC=B2=AD=20API=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../api/app/welfare/rental/AppRentalApi.java | 5 +++++ .../app/welfare/rental/AppRentalController.java | 9 +++++++++ .../domain/rental/domain/RentalErrorCode.java | 3 ++- .../domain/rental/domain/RentalHistory.java | 6 ++++++ .../repository/RentalHistoryRepository.java | 4 ++++ .../rental/service/RentalHistoryService.java | 6 ++++++ .../service/impl/RentalHistoryServiceImpl.java | 16 ++++++++++++++++ .../db/welfare/RentalHistoryJpaRepository.java | 3 +++ .../db/welfare/RentalHistoryRepositoryImpl.java | 7 +++++++ 9 files changed, 58 insertions(+), 1 deletion(-) diff --git a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/rental/AppRentalApi.java b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/rental/AppRentalApi.java index c2cd9d30..e1712182 100644 --- a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/rental/AppRentalApi.java +++ b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/rental/AppRentalApi.java @@ -62,4 +62,9 @@ ApiResponse getHistories( } ) ApiResponse applyRental(AppApiUser apiUser, RentalApplyRequest request); + + /** 반납 신청. 본인 소유의 대여 중 이력만 대상이다. */ + @Operation(summary = "반납 신청", description = "대여 중인 물품의 반납을 신청한다. 신청 즉시 반납 완료로 처리된다.") + @ApiErrorCode(type = RentalErrorCode.class, codes = {"RENTAL_NOT_FOUND"}) + ApiResponse returnRental(AppApiUser apiUser, Long rentalHistoryId); } diff --git a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/rental/AppRentalController.java b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/rental/AppRentalController.java index 108e600d..385feacd 100644 --- a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/rental/AppRentalController.java +++ b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/rental/AppRentalController.java @@ -19,6 +19,8 @@ import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.ModelAttribute; +import org.springframework.web.bind.annotation.PatchMapping; +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; @@ -78,4 +80,11 @@ public ApiResponse applyRental(AppApiUser apiUser, @Valid @RequestBody Ren ); return ApiResponse.success(); } + + @Override + @PatchMapping("/histories/{rentalHistoryId}/return") + public ApiResponse returnRental(AppApiUser apiUser, @PathVariable Long rentalHistoryId) { + rentalHistoryService.returnRental(apiUser.userId(), rentalHistoryId); + return ApiResponse.success(); + } } diff --git a/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/rental/domain/RentalErrorCode.java b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/rental/domain/RentalErrorCode.java index f74647b1..67c8e126 100644 --- a/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/rental/domain/RentalErrorCode.java +++ b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/rental/domain/RentalErrorCode.java @@ -17,7 +17,8 @@ public enum RentalErrorCode implements ErrorCode { ITEM_OUT_OF_STOCK(ErrorStatus.BAD_REQUEST, "재고가 부족합니다."), RENTAL_ITEM_DUPLICATED(ErrorStatus.BAD_REQUEST, "이미 대여 중인 물품입니다."), INVALID_RENTAL_TIME_RANGE(ErrorStatus.BAD_REQUEST, "대여 가능 시간이 아니에요. 10시-17시 사이로 선택해 주세요."), - INVALID_RENTAL_TIME_LUNCH_BREAK(ErrorStatus.BAD_REQUEST, "점심시간(12시-13시)에는 대여가 불가능해요."); + INVALID_RENTAL_TIME_LUNCH_BREAK(ErrorStatus.BAD_REQUEST, "점심시간(12시-13시)에는 대여가 불가능해요."), + RENTAL_NOT_FOUND(ErrorStatus.NOT_FOUND, "존재하지 않는 대여 이력입니다."); private final int status; private final String message; diff --git a/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/rental/domain/RentalHistory.java b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/rental/domain/RentalHistory.java index 962dac9d..93fab0dc 100644 --- a/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/rental/domain/RentalHistory.java +++ b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/rental/domain/RentalHistory.java @@ -35,6 +35,12 @@ public static RentalHistory create(Item item, Long memberId, int rentedCount, Lo return new RentalHistory(null, item.getId(), memberId, null, null, status, rentedCount, appliedAt, rentAt, returnedAt); } + /** 반납 신청 처리. 운영진의 최종 확인 단계가 없어 신청 즉시 반납 완료로 전이한다. */ + public void markReturned(LocalDateTime returnedAt) { + this.rentalStatus = RentalStatus.RETURNED; + this.returnedAt = returnedAt; + } + public static RentalHistory of( Long id, Long itemId, diff --git a/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/rental/repository/RentalHistoryRepository.java b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/rental/repository/RentalHistoryRepository.java index b541f86b..77b41c6d 100644 --- a/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/rental/repository/RentalHistoryRepository.java +++ b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/rental/repository/RentalHistoryRepository.java @@ -1,6 +1,7 @@ package kr.ac.kookmin.stream.welfare.domain.rental.repository; import java.util.List; +import java.util.Optional; import kr.ac.kookmin.stream.welfare.domain.rental.domain.RentalHistory; import kr.ac.kookmin.stream.welfare.domain.rental.domain.RentalStatus; @@ -17,4 +18,7 @@ public interface RentalHistoryRepository { /** 같은 회원이 같은 물품을 이미 대여 중인지(상태가 RENTAL인 이력이 있는지) 확인한다. */ boolean existsActiveRental(Long itemId, Long memberId); + + /** 반납 신청 대상 조회. 본인 소유가 아니거나 대여 중 상태가 아니면 빈 값이다(둘 다 RENTAL_NOT_FOUND로 처리). */ + Optional findRentalToReturn(Long id, Long memberId); } diff --git a/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/rental/service/RentalHistoryService.java b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/rental/service/RentalHistoryService.java index 20aaac34..c0e0551e 100644 --- a/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/rental/service/RentalHistoryService.java +++ b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/rental/service/RentalHistoryService.java @@ -19,4 +19,10 @@ public interface RentalHistoryService { * 반납 정책이 없는 물품의 대여는 기한을 계산할 수 없어 제외한다. */ List getReturnRequiredRentals(Long memberId); + + /** + * 반납 신청. 본인 소유의 대여 중(RENTAL) 이력만 대상이며, 신청 즉시 반납 완료로 전이한다 + * (운영진의 최종 확인 단계가 없어서다). + */ + void returnRental(Long memberId, Long historyId); } diff --git a/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/rental/service/impl/RentalHistoryServiceImpl.java b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/rental/service/impl/RentalHistoryServiceImpl.java index 327ec3f4..dcac7da1 100644 --- a/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/rental/service/impl/RentalHistoryServiceImpl.java +++ b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/rental/service/impl/RentalHistoryServiceImpl.java @@ -1,10 +1,14 @@ package kr.ac.kookmin.stream.welfare.domain.rental.service.impl; +import java.time.LocalDateTime; +import java.time.ZoneId; import java.util.List; import java.util.Map; import java.util.function.Function; import java.util.stream.Collectors; +import kr.ac.kookmin.stream.common.BusinessException; import kr.ac.kookmin.stream.welfare.domain.rental.domain.Item; +import kr.ac.kookmin.stream.welfare.domain.rental.domain.RentalErrorCode; import kr.ac.kookmin.stream.welfare.domain.rental.domain.RentalHistory; import kr.ac.kookmin.stream.welfare.domain.rental.domain.RentalHistorySummary; import kr.ac.kookmin.stream.welfare.domain.rental.domain.RentalStatus; @@ -20,6 +24,8 @@ @RequiredArgsConstructor class RentalHistoryServiceImpl implements RentalHistoryService { + private static final ZoneId KST = ZoneId.of("Asia/Seoul"); + private final RentalHistoryRepository rentalHistoryRepository; private final ItemRepository itemRepository; @@ -62,4 +68,14 @@ private Map findItemsOf(List histories) { private boolean hasDueAt(RentalHistory history, Item item) { return history.getRentAt() != null && item != null && item.getReturnPolicy() != null; } + + // 조회(대상 확인) + 쓰기(상태 전이)가 원자적으로 묶여야 하므로 일반 트랜잭션이다. + @Override + @Transactional + public void returnRental(Long memberId, Long historyId) { + RentalHistory history = rentalHistoryRepository.findRentalToReturn(historyId, memberId) + .orElseThrow(() -> new BusinessException(RentalErrorCode.RENTAL_NOT_FOUND)); + history.markReturned(LocalDateTime.now(KST)); + rentalHistoryRepository.save(history); + } } diff --git a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/welfare/RentalHistoryJpaRepository.java b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/welfare/RentalHistoryJpaRepository.java index bee7b696..c6e7184a 100644 --- a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/welfare/RentalHistoryJpaRepository.java +++ b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/welfare/RentalHistoryJpaRepository.java @@ -1,6 +1,7 @@ package kr.ac.kookmin.stream.db.welfare; import java.util.List; +import java.util.Optional; import kr.ac.kookmin.stream.welfare.domain.rental.domain.RentalStatus; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; @@ -20,4 +21,6 @@ List findAllByMemberId( ); boolean existsByItemIdAndMemberIdAndRentalStatus(Long itemId, Long memberId, RentalStatus rentalStatus); + + Optional findByIdAndMemberIdAndRentalStatus(Long id, Long memberId, RentalStatus rentalStatus); } diff --git a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/welfare/RentalHistoryRepositoryImpl.java b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/welfare/RentalHistoryRepositoryImpl.java index 5b66dcfd..9f56e190 100644 --- a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/welfare/RentalHistoryRepositoryImpl.java +++ b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/welfare/RentalHistoryRepositoryImpl.java @@ -1,6 +1,7 @@ package kr.ac.kookmin.stream.db.welfare; import java.util.List; +import java.util.Optional; import kr.ac.kookmin.stream.welfare.domain.rental.domain.RentalHistory; import kr.ac.kookmin.stream.welfare.domain.rental.domain.RentalStatus; import kr.ac.kookmin.stream.welfare.domain.rental.repository.RentalHistoryRepository; @@ -29,4 +30,10 @@ public RentalHistory save(RentalHistory history) { public boolean existsActiveRental(Long itemId, Long memberId) { return rentalHistoryJpaRepository.existsByItemIdAndMemberIdAndRentalStatus(itemId, memberId, RentalStatus.RENTAL); } + + @Override + public Optional findRentalToReturn(Long id, Long memberId) { + return rentalHistoryJpaRepository.findByIdAndMemberIdAndRentalStatus(id, memberId, RentalStatus.RENTAL) + .map(RentalHistoryJpaEntity::toDomain); + } } From 4be5982ef13ff0fc0e5389d66db7abff009584b9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=84=9C=EC=A4=80?= <104981505+xeoxxn@users.noreply.github.com> Date: Mon, 28 Sep 2026 11:10:53 +0900 Subject: [PATCH 3/6] =?UTF-8?q?fix:=20=EB=8C=80=EC=97=AC=20=EC=8B=A0?= =?UTF-8?q?=EC=B2=AD=20UseCase=EC=9D=98=20=EB=A0=88=EC=9D=B4=EC=96=B4=20?= =?UTF-8?q?=EC=9C=84=EB=B0=98=EA=B3=BC=20=EC=A4=91=EB=B3=B5=20=EB=8C=80?= =?UTF-8?q?=EC=97=AC=20=EB=8F=99=EC=8B=9C=EC=84=B1=20=EA=B5=AC=EB=A9=8D=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../rental/usecase/RentalApplyUseCase.java | 24 +++++++++---------- .../domain/rental/service/ItemService.java | 7 ++++++ .../rental/service/RentalHistoryService.java | 9 +++++++ .../rental/service/impl/ItemServiceImpl.java | 12 ++++++++++ .../impl/RentalHistoryServiceImpl.java | 13 ++++++++++ 5 files changed, 52 insertions(+), 13 deletions(-) diff --git a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/rental/usecase/RentalApplyUseCase.java b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/rental/usecase/RentalApplyUseCase.java index 430b7118..ea02932d 100644 --- a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/rental/usecase/RentalApplyUseCase.java +++ b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/rental/usecase/RentalApplyUseCase.java @@ -7,9 +7,8 @@ import kr.ac.kookmin.stream.welfare.domain.fee.service.PayerService; import kr.ac.kookmin.stream.welfare.domain.rental.domain.Item; import kr.ac.kookmin.stream.welfare.domain.rental.domain.RentalErrorCode; -import kr.ac.kookmin.stream.welfare.domain.rental.domain.RentalHistory; -import kr.ac.kookmin.stream.welfare.domain.rental.repository.ItemRepository; -import kr.ac.kookmin.stream.welfare.domain.rental.repository.RentalHistoryRepository; +import kr.ac.kookmin.stream.welfare.domain.rental.service.ItemService; +import kr.ac.kookmin.stream.welfare.domain.rental.service.RentalHistoryService; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; import org.springframework.transaction.annotation.Transactional; @@ -25,27 +24,26 @@ public class RentalApplyUseCase { private static final ZoneId KST = ZoneId.of("Asia/Seoul"); private final PayerService payerService; - private final ItemRepository itemRepository; - private final RentalHistoryRepository rentalHistoryRepository; + private final ItemService itemService; + private final RentalHistoryService rentalHistoryService; @Transactional public void apply(Long memberId, Long itemId, int count, int rentAtHour, int rentAtMinute, boolean ignoreDuplicate) { + // itemService.decreaseStock()이 이 트랜잭션의 첫 조회여야 한다 — 그 안의 비관적 락 조회가 + // 먼저 실행돼야, 뒤이은 평범한 조회(isPayer, existsActiveRental)들이 락 획득 이후(= + // 경쟁 상대가 커밋한 이후) 시점의 데이터를 보게 된다(MySQL REPEATABLE READ 스냅샷). + Item item = itemService.decreaseStock(itemId, count); + if (!payerService.isPayer(memberId)) { throw new BusinessException(RentalErrorCode.MEMBER_IS_NOT_PAYER); } - if (!ignoreDuplicate && rentalHistoryRepository.existsActiveRental(itemId, memberId)) { + if (!ignoreDuplicate && rentalHistoryService.existsActiveRental(itemId, memberId)) { throw new BusinessException(RentalErrorCode.RENTAL_ITEM_DUPLICATED); } - // 비관적 락으로 읽어서, 동시에 들어온 다른 신청이 이 물품의 재고를 같이 통과하지 못하게 한다 - Item item = itemRepository.findByIdForUpdate(itemId) - .orElseThrow(() -> new BusinessException(RentalErrorCode.ITEM_NOT_FOUND)); - item.decreaseStock(count); - itemRepository.save(item); - LocalDateTime now = LocalDateTime.now(KST); LocalDateTime rentAt = LocalDate.now(KST).atTime(rentAtHour, rentAtMinute); - rentalHistoryRepository.save(RentalHistory.create(item, memberId, count, now, rentAt)); + rentalHistoryService.create(item, memberId, count, now, rentAt); } } diff --git a/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/rental/service/ItemService.java b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/rental/service/ItemService.java index 63448314..ae81986d 100644 --- a/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/rental/service/ItemService.java +++ b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/rental/service/ItemService.java @@ -15,4 +15,11 @@ public interface ItemService { * @param cursor 이전 페이지의 마지막 항목. null이면 첫 페이지 */ CursorSliceResult getItems(ItemCategory category, String keyword, ItemCursor cursor, int size); + + /** + * 재고를 비관적 락으로 안전하게 차감한다. 이 메서드는 항상 비관적 락 조회로 시작해야 한다 — + * 호출부(대여 신청)의 트랜잭션에서 이 호출이 첫 조회가 되어야, 뒤이은 다른 평범한 조회들이 + * 락 획득 이후 시점의 데이터를 보게 된다. + */ + Item decreaseStock(Long itemId, int amount); } diff --git a/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/rental/service/RentalHistoryService.java b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/rental/service/RentalHistoryService.java index c0e0551e..e981a9a1 100644 --- a/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/rental/service/RentalHistoryService.java +++ b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/rental/service/RentalHistoryService.java @@ -1,6 +1,9 @@ package kr.ac.kookmin.stream.welfare.domain.rental.service; +import java.time.LocalDateTime; import java.util.List; +import kr.ac.kookmin.stream.welfare.domain.rental.domain.Item; +import kr.ac.kookmin.stream.welfare.domain.rental.domain.RentalHistory; import kr.ac.kookmin.stream.welfare.domain.rental.domain.RentalHistorySummary; import kr.ac.kookmin.stream.welfare.domain.rental.domain.RentalStatus; import kr.ac.kookmin.stream.welfare.domain.rental.domain.ReturnRequiredRental; @@ -25,4 +28,10 @@ public interface RentalHistoryService { * (운영진의 최종 확인 단계가 없어서다). */ void returnRental(Long memberId, Long historyId); + + /** 같은 회원이 같은 물품을 이미 대여 중인지(상태가 RENTAL인 이력이 있는지) 확인한다. */ + boolean existsActiveRental(Long itemId, Long memberId); + + /** 대여 신청으로 새 이력을 만들고 저장한다. */ + RentalHistory create(Item item, Long memberId, int count, LocalDateTime appliedAt, LocalDateTime rentAt); } diff --git a/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/rental/service/impl/ItemServiceImpl.java b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/rental/service/impl/ItemServiceImpl.java index 88aa630f..d23ff551 100644 --- a/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/rental/service/impl/ItemServiceImpl.java +++ b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/rental/service/impl/ItemServiceImpl.java @@ -1,13 +1,16 @@ package kr.ac.kookmin.stream.welfare.domain.rental.service.impl; +import kr.ac.kookmin.stream.common.BusinessException; import kr.ac.kookmin.stream.common.CursorSliceResult; import kr.ac.kookmin.stream.welfare.domain.rental.domain.Item; import kr.ac.kookmin.stream.welfare.domain.rental.domain.ItemCategory; import kr.ac.kookmin.stream.welfare.domain.rental.domain.ItemCursor; +import kr.ac.kookmin.stream.welfare.domain.rental.domain.RentalErrorCode; import kr.ac.kookmin.stream.welfare.domain.rental.repository.ItemRepository; import kr.ac.kookmin.stream.welfare.domain.rental.service.ItemService; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; @Service @RequiredArgsConstructor @@ -20,4 +23,13 @@ class ItemServiceImpl implements ItemService { public CursorSliceResult getItems(ItemCategory category, String keyword, ItemCursor cursor, int size) { return itemRepository.findSlice(category, keyword, cursor, size); } + + @Override + @Transactional + public Item decreaseStock(Long itemId, int amount) { + Item item = itemRepository.findByIdForUpdate(itemId) + .orElseThrow(() -> new BusinessException(RentalErrorCode.ITEM_NOT_FOUND)); + item.decreaseStock(amount); + return itemRepository.save(item); + } } diff --git a/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/rental/service/impl/RentalHistoryServiceImpl.java b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/rental/service/impl/RentalHistoryServiceImpl.java index dcac7da1..4fd914f0 100644 --- a/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/rental/service/impl/RentalHistoryServiceImpl.java +++ b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/rental/service/impl/RentalHistoryServiceImpl.java @@ -78,4 +78,17 @@ public void returnRental(Long memberId, Long historyId) { history.markReturned(LocalDateTime.now(KST)); rentalHistoryRepository.save(history); } + + // 단일 조회라 dirty-checking 이득이 없어 트랜잭션을 걸지 않는다. 대여 신청(RentalApplyUseCase)의 + // 트랜잭션 안에서 호출되므로, 재고 락 조회 이후에 고정된 스냅샷을 그대로 공유해서 쓴다. + @Override + public boolean existsActiveRental(Long itemId, Long memberId) { + return rentalHistoryRepository.existsActiveRental(itemId, memberId); + } + + @Override + @Transactional + public RentalHistory create(Item item, Long memberId, int count, LocalDateTime appliedAt, LocalDateTime rentAt) { + return rentalHistoryRepository.save(RentalHistory.create(item, memberId, count, appliedAt, rentAt)); + } } From 508b16bf7f40838beb82256a4257c7ac2ecd4059 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=84=9C=EC=A4=80?= <104981505+xeoxxn@users.noreply.github.com> Date: Mon, 28 Sep 2026 14:21:37 +0900 Subject: [PATCH 4/6] =?UTF-8?q?refactor:=20=EC=9E=AC=EA=B3=A0=20=EC=B0=A8?= =?UTF-8?q?=EA=B0=90=20=EB=B9=84=EA=B4=80=EC=A0=81=20=EB=9D=BD=20=EC=A0=9C?= =?UTF-8?q?=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../app/welfare/rental/usecase/RentalApplyUseCase.java | 9 ++++----- .../welfare/domain/rental/repository/ItemRepository.java | 3 +-- .../welfare/domain/rental/service/ItemService.java | 9 ++++++--- .../domain/rental/service/impl/ItemServiceImpl.java | 2 +- .../ac/kookmin/stream/db/welfare/ItemJpaRepository.java | 9 --------- .../ac/kookmin/stream/db/welfare/ItemRepositoryImpl.java | 4 ++-- 6 files changed, 14 insertions(+), 22 deletions(-) diff --git a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/rental/usecase/RentalApplyUseCase.java b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/rental/usecase/RentalApplyUseCase.java index ea02932d..7a94b51a 100644 --- a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/rental/usecase/RentalApplyUseCase.java +++ b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/rental/usecase/RentalApplyUseCase.java @@ -29,11 +29,8 @@ public class RentalApplyUseCase { @Transactional public void apply(Long memberId, Long itemId, int count, int rentAtHour, int rentAtMinute, boolean ignoreDuplicate) { - // itemService.decreaseStock()이 이 트랜잭션의 첫 조회여야 한다 — 그 안의 비관적 락 조회가 - // 먼저 실행돼야, 뒤이은 평범한 조회(isPayer, existsActiveRental)들이 락 획득 이후(= - // 경쟁 상대가 커밋한 이후) 시점의 데이터를 보게 된다(MySQL REPEATABLE READ 스냅샷). - Item item = itemService.decreaseStock(itemId, count); - + // 동시성 보호(락) 없이 진행한다 — 재고 경쟁, 중복 대여 경쟁 둘 다 이론적으로 남아있는 + // 경합이다. 필요해지면 별도로 다시 도입한다(billilge-rental-apply-review-fixes.md 참고). if (!payerService.isPayer(memberId)) { throw new BusinessException(RentalErrorCode.MEMBER_IS_NOT_PAYER); } @@ -42,6 +39,8 @@ public void apply(Long memberId, Long itemId, int count, int rentAtHour, int ren throw new BusinessException(RentalErrorCode.RENTAL_ITEM_DUPLICATED); } + Item item = itemService.decreaseStock(itemId, count); + LocalDateTime now = LocalDateTime.now(KST); LocalDateTime rentAt = LocalDate.now(KST).atTime(rentAtHour, rentAtMinute); rentalHistoryService.create(item, memberId, count, now, rentAt); diff --git a/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/rental/repository/ItemRepository.java b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/rental/repository/ItemRepository.java index 08e7e93b..4022a086 100644 --- a/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/rental/repository/ItemRepository.java +++ b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/rental/repository/ItemRepository.java @@ -21,8 +21,7 @@ public interface ItemRepository { List findAllByIds(Collection ids); - /** 재고 차감용 비관적 락 조회. 동시에 들어온 대여 신청이 같은 물품의 재고를 동시에 읽지 못하게 한다. */ - Optional findByIdForUpdate(Long id); + Optional findById(Long id); Item save(Item item); } diff --git a/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/rental/service/ItemService.java b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/rental/service/ItemService.java index ae81986d..398241c8 100644 --- a/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/rental/service/ItemService.java +++ b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/rental/service/ItemService.java @@ -17,9 +17,12 @@ public interface ItemService { CursorSliceResult getItems(ItemCategory category, String keyword, ItemCursor cursor, int size); /** - * 재고를 비관적 락으로 안전하게 차감한다. 이 메서드는 항상 비관적 락 조회로 시작해야 한다 — - * 호출부(대여 신청)의 트랜잭션에서 이 호출이 첫 조회가 되어야, 뒤이은 다른 평범한 조회들이 - * 락 획득 이후 시점의 데이터를 보게 된다. + * 재고를 차감한다. + *

+ * 동시성 보호(락)는 아직 없다 — 동시에 들어온 두 요청이 같은 물품의 재고를 동시에 통과해 + * 재고가 음수로 내려갈 수 있는 경합이 이론적으로 남아있다. 이 프로젝트 규모에서 실제로 + * 문제된 적은 없어 보이는 레거시(billilge/backend)와 같은 수준으로, 일단 보호 없이 간다 + * (`billilge-rental-apply-review-fixes.md` 참고 — 필요해지면 별도로 다시 도입한다). */ Item decreaseStock(Long itemId, int amount); } diff --git a/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/rental/service/impl/ItemServiceImpl.java b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/rental/service/impl/ItemServiceImpl.java index d23ff551..a5373542 100644 --- a/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/rental/service/impl/ItemServiceImpl.java +++ b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/rental/service/impl/ItemServiceImpl.java @@ -27,7 +27,7 @@ public CursorSliceResult getItems(ItemCategory category, String keyword, I @Override @Transactional public Item decreaseStock(Long itemId, int amount) { - Item item = itemRepository.findByIdForUpdate(itemId) + Item item = itemRepository.findById(itemId) .orElseThrow(() -> new BusinessException(RentalErrorCode.ITEM_NOT_FOUND)); item.decreaseStock(amount); return itemRepository.save(item); diff --git a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/welfare/ItemJpaRepository.java b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/welfare/ItemJpaRepository.java index e9fd3c2c..f1432b8f 100644 --- a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/welfare/ItemJpaRepository.java +++ b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/welfare/ItemJpaRepository.java @@ -1,23 +1,14 @@ package kr.ac.kookmin.stream.db.welfare; -import jakarta.persistence.LockModeType; import java.util.List; -import java.util.Optional; import kr.ac.kookmin.stream.welfare.domain.rental.domain.ItemCategory; import org.springframework.data.domain.Pageable; 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; public interface ItemJpaRepository extends JpaRepository { - // 대여 신청의 재고 차감용. 비관적 락으로 읽어서, 같은 물품에 동시에 들어온 신청이 서로 다른 - // 트랜잭션의 커밋을 기다리게 한다(그냥 SELECT는 락이 없어 두 요청이 같은 재고를 동시에 통과할 수 있다). - @Lock(LockModeType.PESSIMISTIC_WRITE) - @Query("SELECT i FROM ItemJpaEntity i WHERE i.id = :id") - Optional findByIdForUpdate(@Param("id") Long id); - // keywordPattern은 LIKE 패턴이다. 검색어의 %·_를 이스케이프한 뒤 '!'를 이스케이프 문자로 쓴다 // ('\\'는 MySQL 문자열 리터럴에서 다시 이스케이프되어 쓸 수 없다) @Query(""" diff --git a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/welfare/ItemRepositoryImpl.java b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/welfare/ItemRepositoryImpl.java index bd423874..17e89dbe 100644 --- a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/welfare/ItemRepositoryImpl.java +++ b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/welfare/ItemRepositoryImpl.java @@ -41,8 +41,8 @@ public List findAllByIds(Collection ids) { } @Override - public Optional findByIdForUpdate(Long id) { - return itemJpaRepository.findByIdForUpdate(id).map(ItemJpaEntity::toDomain); + public Optional findById(Long id) { + return itemJpaRepository.findById(id).map(ItemJpaEntity::toDomain); } @Override From 383db1632b64eb1301024df6d281a31adc199b7c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=84=9C=EC=A4=80?= <104981505+xeoxxn@users.noreply.github.com> Date: Tue, 29 Sep 2026 15:19:33 +0900 Subject: [PATCH 5/6] =?UTF-8?q?refactor:=20=ED=9A=8C=EB=B9=84=20=EB=82=A9?= =?UTF-8?q?=EB=B6=80=20=EA=B2=80=EC=A6=9D=EC=9D=84=20PayerService=EB=A1=9C?= =?UTF-8?q?=20=EC=9D=B4=EB=8F=99,=20MEMBER=5FIS=5FNOT=5FPAYER=EB=A5=BC=20F?= =?UTF-8?q?eeErrorCode=EB=A1=9C=20=EC=9E=AC=EB=B0=B0=EC=B9=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../stream/api/app/welfare/rental/AppRentalApi.java | 4 +++- .../app/welfare/rental/usecase/RentalApplyUseCase.java | 4 +--- .../stream/welfare/domain/fee/domain/FeeErrorCode.java | 3 ++- .../stream/welfare/domain/fee/service/PayerService.java | 4 ++-- .../welfare/domain/fee/service/impl/PayerServiceImpl.java | 8 ++++++-- .../welfare/domain/rental/domain/RentalErrorCode.java | 1 - 6 files changed, 14 insertions(+), 10 deletions(-) diff --git a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/rental/AppRentalApi.java b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/rental/AppRentalApi.java index e1712182..2f127966 100644 --- a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/rental/AppRentalApi.java +++ b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/rental/AppRentalApi.java @@ -13,6 +13,7 @@ import kr.ac.kookmin.stream.api.common.dto.CursorSliceResponse; import kr.ac.kookmin.stream.api.common.openapi.ApiErrorCode; import kr.ac.kookmin.stream.common.CommonErrorCode; +import kr.ac.kookmin.stream.welfare.domain.fee.domain.FeeErrorCode; import kr.ac.kookmin.stream.welfare.domain.rental.domain.RentalErrorCode; import org.springdoc.core.annotations.ParameterObject; @@ -54,10 +55,11 @@ ApiResponse getHistories( + "이미 대여 중인 같은 물품이 있으면 거부하되, ignoreDuplicate=true면 건너뛴다. " + "소모품은 신청 즉시 반납 완료로, 대여품은 대여 중으로 등록된다.") @ApiErrorCode(type = CommonErrorCode.class, codes = {"INVALID_INPUT"}) + @ApiErrorCode(type = FeeErrorCode.class, codes = {"MEMBER_IS_NOT_PAYER"}) @ApiErrorCode( type = RentalErrorCode.class, codes = { - "ITEM_NOT_FOUND", "MEMBER_IS_NOT_PAYER", "ITEM_OUT_OF_STOCK", "RENTAL_ITEM_DUPLICATED", + "ITEM_NOT_FOUND", "ITEM_OUT_OF_STOCK", "RENTAL_ITEM_DUPLICATED", "INVALID_RENTAL_TIME_RANGE", "INVALID_RENTAL_TIME_LUNCH_BREAK" } ) diff --git a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/rental/usecase/RentalApplyUseCase.java b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/rental/usecase/RentalApplyUseCase.java index 7a94b51a..687b717b 100644 --- a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/rental/usecase/RentalApplyUseCase.java +++ b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/rental/usecase/RentalApplyUseCase.java @@ -31,9 +31,7 @@ public class RentalApplyUseCase { public void apply(Long memberId, Long itemId, int count, int rentAtHour, int rentAtMinute, boolean ignoreDuplicate) { // 동시성 보호(락) 없이 진행한다 — 재고 경쟁, 중복 대여 경쟁 둘 다 이론적으로 남아있는 // 경합이다. 필요해지면 별도로 다시 도입한다(billilge-rental-apply-review-fixes.md 참고). - if (!payerService.isPayer(memberId)) { - throw new BusinessException(RentalErrorCode.MEMBER_IS_NOT_PAYER); - } + payerService.validatePayer(memberId); if (!ignoreDuplicate && rentalHistoryService.existsActiveRental(itemId, memberId)) { throw new BusinessException(RentalErrorCode.RENTAL_ITEM_DUPLICATED); diff --git a/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/fee/domain/FeeErrorCode.java b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/fee/domain/FeeErrorCode.java index 50a00db7..e3eb793f 100644 --- a/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/fee/domain/FeeErrorCode.java +++ b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/fee/domain/FeeErrorCode.java @@ -15,7 +15,8 @@ public enum FeeErrorCode implements ErrorCode { INVALID_TRANSFER_STATUS(ErrorStatus.BAD_REQUEST, "유효하지 않은 납부 상태입니다."), FEE_REQUEST_NOT_FOUND(ErrorStatus.NOT_FOUND, "존재하지 않는 납부 확인 요청입니다."), FEE_REQUEST_ALREADY_REVIEWED(ErrorStatus.CONFLICT, "이미 처리된 납부 확인 요청입니다."), - FEE_TRANSFER_LINK_INVALID(ErrorStatus.INTERNAL_SERVER_ERROR, "송금 요청 URL이 생성되지 않았습니다. 관리자에게 문의해 주세요."); + FEE_TRANSFER_LINK_INVALID(ErrorStatus.INTERNAL_SERVER_ERROR, "송금 요청 URL이 생성되지 않았습니다. 관리자에게 문의해 주세요."), + MEMBER_IS_NOT_PAYER(ErrorStatus.BAD_REQUEST, "회비를 납부한 회원만 이용할 수 있습니다."); private final int status; private final String message; diff --git a/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/fee/service/PayerService.java b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/fee/service/PayerService.java index 585dbadb..b9e168d3 100644 --- a/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/fee/service/PayerService.java +++ b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/fee/service/PayerService.java @@ -7,6 +7,6 @@ public interface PayerService { // 확인요청 처리 결과를 납부자 명부(Payer)에 반영한다. PAID면 등록, 그 외(UNPAID)면 명부에서 제거한다. void sync(Long memberId, String name, String studentId, TransferStatus status); - // 회원이 납부자 명부에 등록돼 있는지 확인한다(회비 납부 여부가 필요한 다른 도메인의 조건 확인용). - boolean isPayer(Long memberId); + // 회원이 납부자 명부에 등록돼 있는지 확인하고, 아니면 예외를 던진다(회비 납부 여부가 필요한 다른 도메인의 조건 확인용). + void validatePayer(Long memberId); } diff --git a/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/fee/service/impl/PayerServiceImpl.java b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/fee/service/impl/PayerServiceImpl.java index 7b0d8404..74fc041d 100644 --- a/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/fee/service/impl/PayerServiceImpl.java +++ b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/fee/service/impl/PayerServiceImpl.java @@ -1,5 +1,7 @@ package kr.ac.kookmin.stream.welfare.domain.fee.service.impl; +import kr.ac.kookmin.stream.common.BusinessException; +import kr.ac.kookmin.stream.welfare.domain.fee.domain.FeeErrorCode; import kr.ac.kookmin.stream.welfare.domain.fee.domain.Payer; import kr.ac.kookmin.stream.welfare.domain.fee.domain.TransferStatus; import kr.ac.kookmin.stream.welfare.domain.fee.repository.PayerRepository; @@ -39,7 +41,9 @@ private String enrollmentYearOf(String studentId) { // 조회 쿼리가 1개라 트랜잭션을 걸지 않는다(PR#67/#69 원칙 — 조회니까 붙이는 게 아니라 실제 이득이 있을 때만 붙인다). @Override - public boolean isPayer(Long memberId) { - return payerRepository.findByMemberId(memberId).isPresent(); + public void validatePayer(Long memberId) { + if (payerRepository.findByMemberId(memberId).isEmpty()) { + throw new BusinessException(FeeErrorCode.MEMBER_IS_NOT_PAYER); + } } } diff --git a/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/rental/domain/RentalErrorCode.java b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/rental/domain/RentalErrorCode.java index 67c8e126..a577fd75 100644 --- a/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/rental/domain/RentalErrorCode.java +++ b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/rental/domain/RentalErrorCode.java @@ -13,7 +13,6 @@ public enum RentalErrorCode implements ErrorCode { ITEM_INVALID_CURSOR(ErrorStatus.BAD_REQUEST, "유효하지 않은 커서입니다."), ITEM_NOT_FOUND(ErrorStatus.NOT_FOUND, "존재하지 않는 물품입니다."), - MEMBER_IS_NOT_PAYER(ErrorStatus.BAD_REQUEST, "회비를 납부한 회원만 이용할 수 있습니다."), ITEM_OUT_OF_STOCK(ErrorStatus.BAD_REQUEST, "재고가 부족합니다."), RENTAL_ITEM_DUPLICATED(ErrorStatus.BAD_REQUEST, "이미 대여 중인 물품입니다."), INVALID_RENTAL_TIME_RANGE(ErrorStatus.BAD_REQUEST, "대여 가능 시간이 아니에요. 10시-17시 사이로 선택해 주세요."), From 66f9281fdbe4bbb4ce45fc161304ccafc26d8c73 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=84=9C=EC=A4=80?= <104981505+xeoxxn@users.noreply.github.com> Date: Tue, 29 Sep 2026 15:36:41 +0900 Subject: [PATCH 6/6] =?UTF-8?q?refactor:=20=EC=A4=91=EB=B3=B5=EB=90=9C=20K?= =?UTF-8?q?ST=20=EC=83=81=EC=88=98=EB=A5=BC=20DateUtil=EB=A1=9C=20?= =?UTF-8?q?=EC=B6=94=EC=B6=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../app/welfare/rental/usecase/RentalApplyUseCase.java | 8 +++----- .../main/java/kr/ac/kookmin/stream/common/DateUtil.java | 4 ++++ .../rental/service/impl/RentalHistoryServiceImpl.java | 6 ++---- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/rental/usecase/RentalApplyUseCase.java b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/rental/usecase/RentalApplyUseCase.java index 687b717b..d34219ec 100644 --- a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/rental/usecase/RentalApplyUseCase.java +++ b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/rental/usecase/RentalApplyUseCase.java @@ -2,8 +2,8 @@ import java.time.LocalDate; import java.time.LocalDateTime; -import java.time.ZoneId; import kr.ac.kookmin.stream.common.BusinessException; +import kr.ac.kookmin.stream.common.DateUtil; import kr.ac.kookmin.stream.welfare.domain.fee.service.PayerService; import kr.ac.kookmin.stream.welfare.domain.rental.domain.Item; import kr.ac.kookmin.stream.welfare.domain.rental.domain.RentalErrorCode; @@ -21,8 +21,6 @@ @RequiredArgsConstructor public class RentalApplyUseCase { - private static final ZoneId KST = ZoneId.of("Asia/Seoul"); - private final PayerService payerService; private final ItemService itemService; private final RentalHistoryService rentalHistoryService; @@ -39,8 +37,8 @@ public void apply(Long memberId, Long itemId, int count, int rentAtHour, int ren Item item = itemService.decreaseStock(itemId, count); - LocalDateTime now = LocalDateTime.now(KST); - LocalDateTime rentAt = LocalDate.now(KST).atTime(rentAtHour, rentAtMinute); + LocalDateTime now = LocalDateTime.now(DateUtil.KST); + LocalDateTime rentAt = LocalDate.now(DateUtil.KST).atTime(rentAtHour, rentAtMinute); rentalHistoryService.create(item, memberId, count, now, rentAt); } } diff --git a/core/common/src/main/java/kr/ac/kookmin/stream/common/DateUtil.java b/core/common/src/main/java/kr/ac/kookmin/stream/common/DateUtil.java index 5629d8a4..948a8cc8 100644 --- a/core/common/src/main/java/kr/ac/kookmin/stream/common/DateUtil.java +++ b/core/common/src/main/java/kr/ac/kookmin/stream/common/DateUtil.java @@ -1,6 +1,7 @@ package kr.ac.kookmin.stream.common; import java.time.LocalDate; +import java.time.ZoneId; import lombok.AccessLevel; import lombok.NoArgsConstructor; @@ -10,6 +11,9 @@ @NoArgsConstructor(access = AccessLevel.PRIVATE) public final class DateUtil { + /** 서버 기본 타임존에 기대지 않고 명시적으로 한국 표준시를 써야 하는 곳에 쓴다. */ + public static final ZoneId KST = ZoneId.of("Asia/Seoul"); + private static final int FIRST_MONTH = 1; private static final int FIRST_DAY = 1; diff --git a/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/rental/service/impl/RentalHistoryServiceImpl.java b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/rental/service/impl/RentalHistoryServiceImpl.java index 4fd914f0..b009bcd4 100644 --- a/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/rental/service/impl/RentalHistoryServiceImpl.java +++ b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/rental/service/impl/RentalHistoryServiceImpl.java @@ -1,12 +1,12 @@ package kr.ac.kookmin.stream.welfare.domain.rental.service.impl; import java.time.LocalDateTime; -import java.time.ZoneId; import java.util.List; import java.util.Map; import java.util.function.Function; import java.util.stream.Collectors; import kr.ac.kookmin.stream.common.BusinessException; +import kr.ac.kookmin.stream.common.DateUtil; import kr.ac.kookmin.stream.welfare.domain.rental.domain.Item; import kr.ac.kookmin.stream.welfare.domain.rental.domain.RentalErrorCode; import kr.ac.kookmin.stream.welfare.domain.rental.domain.RentalHistory; @@ -24,8 +24,6 @@ @RequiredArgsConstructor class RentalHistoryServiceImpl implements RentalHistoryService { - private static final ZoneId KST = ZoneId.of("Asia/Seoul"); - private final RentalHistoryRepository rentalHistoryRepository; private final ItemRepository itemRepository; @@ -75,7 +73,7 @@ private boolean hasDueAt(RentalHistory history, Item item) { public void returnRental(Long memberId, Long historyId) { RentalHistory history = rentalHistoryRepository.findRentalToReturn(historyId, memberId) .orElseThrow(() -> new BusinessException(RentalErrorCode.RENTAL_NOT_FOUND)); - history.markReturned(LocalDateTime.now(KST)); + history.markReturned(LocalDateTime.now(DateUtil.KST)); rentalHistoryRepository.save(history); }