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..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 @@ -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; @@ -12,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; @@ -46,4 +48,25 @@ 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 = FeeErrorCode.class, codes = {"MEMBER_IS_NOT_PAYER"}) + @ApiErrorCode( + type = RentalErrorCode.class, + codes = { + "ITEM_NOT_FOUND", "ITEM_OUT_OF_STOCK", "RENTAL_ITEM_DUPLICATED", + "INVALID_RENTAL_TIME_RANGE", "INVALID_RENTAL_TIME_LUNCH_BREAK" + } + ) + 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 974387ba..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 @@ -3,19 +3,26 @@ 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.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; import org.springframework.web.bind.annotation.RestController; @@ -26,6 +33,7 @@ public class AppRentalController implements AppRentalApi { private final ItemService itemService; private final RentalHistoryService rentalHistoryService; + private final RentalApplyUseCase rentalApplyUseCase; @Override @GetMapping("/items") @@ -56,4 +64,27 @@ 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(); + } + + @Override + @PatchMapping("/histories/{rentalHistoryId}/return") + public ApiResponse returnRental(AppApiUser apiUser, @PathVariable Long rentalHistoryId) { + rentalHistoryService.returnRental(apiUser.userId(), rentalHistoryId); + 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..d34219ec --- /dev/null +++ b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/rental/usecase/RentalApplyUseCase.java @@ -0,0 +1,44 @@ +package kr.ac.kookmin.stream.api.app.welfare.rental.usecase; + +import java.time.LocalDate; +import java.time.LocalDateTime; +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; +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; + +/** + * 대여 신청. {@code fee}(회비 납부 확인)와 {@code rental}(재고 차감, 이력 생성) 두 도메인을 조합한다. + * 대여 시간(영업시간·점심시간) 검증은 DB에 닿지 않으므로 이 트랜잭션에 들어오기 전, 컨트롤러에서 끝낸다. + */ +@Component +@RequiredArgsConstructor +public class RentalApplyUseCase { + + private final PayerService payerService; + private final ItemService itemService; + private final RentalHistoryService rentalHistoryService; + + @Transactional + public void apply(Long memberId, Long itemId, int count, int rentAtHour, int rentAtMinute, boolean ignoreDuplicate) { + // 동시성 보호(락) 없이 진행한다 — 재고 경쟁, 중복 대여 경쟁 둘 다 이론적으로 남아있는 + // 경합이다. 필요해지면 별도로 다시 도입한다(billilge-rental-apply-review-fixes.md 참고). + payerService.validatePayer(memberId); + + if (!ignoreDuplicate && rentalHistoryService.existsActiveRental(itemId, memberId)) { + throw new BusinessException(RentalErrorCode.RENTAL_ITEM_DUPLICATED); + } + + Item item = itemService.decreaseStock(itemId, count); + + 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/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 f988a3e6..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 @@ -6,4 +6,7 @@ public interface PayerService { // 확인요청 처리 결과를 납부자 명부(Payer)에 반영한다. PAID면 등록, 그 외(UNPAID)면 명부에서 제거한다. void sync(Long memberId, String name, String studentId, TransferStatus status); + + // 회원이 납부자 명부에 등록돼 있는지 확인하고, 아니면 예외를 던진다(회비 납부 여부가 필요한 다른 도메인의 조건 확인용). + 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 5ba1fb3f..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; @@ -36,4 +38,12 @@ 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 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/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..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 @@ -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, "존재하지 않는 물품입니다."), + 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시)에는 대여가 불가능해요."), + 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 f135e33f..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 @@ -22,6 +22,25 @@ 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 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/ItemRepository.java b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/rental/repository/ItemRepository.java index 9747cf48..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 @@ -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,8 @@ public interface ItemRepository { CursorSliceResult findSlice(ItemCategory category, String keyword, ItemCursor cursor, int size); List findAllByIds(Collection ids); + + Optional findById(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..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; @@ -12,4 +13,12 @@ public interface RentalHistoryRepository { * @param status 대여 상태. null이면 전체 */ List findAllByMemberId(Long memberId, RentalStatus status); + + RentalHistory save(RentalHistory history); + + /** 같은 회원이 같은 물품을 이미 대여 중인지(상태가 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/ItemService.java b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/rental/service/ItemService.java index 63448314..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 @@ -15,4 +15,14 @@ public interface ItemService { * @param cursor 이전 페이지의 마지막 항목. null이면 첫 페이지 */ 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/RentalHistoryService.java b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/rental/service/RentalHistoryService.java index 20aaac34..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; @@ -19,4 +22,16 @@ public interface RentalHistoryService { * 반납 정책이 없는 물품의 대여는 기한을 계산할 수 없어 제외한다. */ List getReturnRequiredRentals(Long memberId); + + /** + * 반납 신청. 본인 소유의 대여 중(RENTAL) 이력만 대상이며, 신청 즉시 반납 완료로 전이한다 + * (운영진의 최종 확인 단계가 없어서다). + */ + 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/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/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..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 @@ -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.findById(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 327ec3f4..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,10 +1,14 @@ package kr.ac.kookmin.stream.welfare.domain.rental.service.impl; +import java.time.LocalDateTime; 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; import kr.ac.kookmin.stream.welfare.domain.rental.domain.RentalHistorySummary; import kr.ac.kookmin.stream.welfare.domain.rental.domain.RentalStatus; @@ -62,4 +66,27 @@ 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(DateUtil.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)); + } } 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..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 @@ -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 findById(Long id) { + return itemJpaRepository.findById(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..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; @@ -18,4 +19,8 @@ List findAllByMemberId( @Param("memberId") Long memberId, @Param("status") RentalStatus status ); + + 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 8dfe1494..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; @@ -19,4 +20,20 @@ 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); + } + + @Override + public Optional findRentalToReturn(Long id, Long memberId) { + return rentalHistoryJpaRepository.findByIdAndMemberIdAndRentalStatus(id, memberId, RentalStatus.RENTAL) + .map(RentalHistoryJpaEntity::toDomain); + } }