From 946f31bd6b6628fae09a08ea173624f76104eddb Mon Sep 17 00:00:00 2001 From: jjunh33 Date: Sun, 27 Sep 2026 03:41:49 +0900 Subject: [PATCH 1/8] =?UTF-8?q?feat:=20=EB=AC=BC=ED=92=88=20=EC=B9=B4?= =?UTF-8?q?=ED=85=8C=EA=B3=A0=EB=A6=AC=C2=B7=EB=B0=98=EB=82=A9=20=EC=A0=95?= =?UTF-8?q?=EC=B1=85=20=EC=BB=AC=EB=9F=BC=EA=B3=BC=20=EB=8C=80=EC=97=AC=20?= =?UTF-8?q?=EC=9D=B4=EB=A0=A5=20=EC=9D=B8=EB=8D=B1=EC=8A=A4=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 --- .../welfare/domain/rental/domain/Item.java | 17 ++++++++-- .../domain/rental/domain/ItemCategory.java | 22 +++++++++++++ .../domain/rental/domain/ReturnPolicy.java | 20 ++++++++++++ .../stream/db/welfare/ItemJpaEntity.java | 29 +++++++++++++++-- .../db/welfare/RentalHistoryJpaEntity.java | 2 +- ...0__add_item_category_and_return_policy.sql | 31 +++++++++++++++++++ 6 files changed, 116 insertions(+), 5 deletions(-) create mode 100644 core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/rental/domain/ItemCategory.java create mode 100644 core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/rental/domain/ReturnPolicy.java create mode 100644 infrastructure/db/src/main/resources/db/migration/V10__add_item_category_and_return_policy.sql 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 76b1861a..a10c5342 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 @@ -12,11 +12,24 @@ public class Item { private Long id; private String name; + private ItemCategory category; private ItemType type; private int count; private String imageKey; + /** 대여품(RENTAL)의 반납 정책. 소모품이거나 정책이 채워지지 않았으면 null이다. */ + private ReturnPolicy returnPolicy; - public static Item of(Long id, String name, ItemType type, int count, String imageKey) { - return new Item(id, name, type, count, imageKey); + public static Item of( + Long id, + String name, + ItemCategory category, + ItemType type, + int count, + String imageKey, + ReturnPolicy returnPolicy + ) { + // 소모품은 반납하지 않으므로 저장된 값이 있어도 반납 정책을 갖지 않는다 + ReturnPolicy applicablePolicy = type == ItemType.RENTAL ? returnPolicy : null; + return new Item(id, name, category, type, count, imageKey, applicablePolicy); } } diff --git a/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/rental/domain/ItemCategory.java b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/rental/domain/ItemCategory.java new file mode 100644 index 00000000..19221e15 --- /dev/null +++ b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/rental/domain/ItemCategory.java @@ -0,0 +1,22 @@ +package kr.ac.kookmin.stream.welfare.domain.rental.domain; + +import kr.ac.kookmin.stream.common.BusinessException; +import kr.ac.kookmin.stream.common.CommonErrorCode; + +public enum ItemCategory { + ELECTRONICS, + DAILY_SUPPLIES, + MEDICINE, + HYGIENE; + + public static ItemCategory from(String value) { + if (value == null) { + return null; + } + try { + return ItemCategory.valueOf(value); + } catch (IllegalArgumentException e) { + throw new BusinessException(CommonErrorCode.INVALID_INPUT); + } + } +} diff --git a/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/rental/domain/ReturnPolicy.java b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/rental/domain/ReturnPolicy.java new file mode 100644 index 00000000..abf00b19 --- /dev/null +++ b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/rental/domain/ReturnPolicy.java @@ -0,0 +1,20 @@ +package kr.ac.kookmin.stream.welfare.domain.rental.domain; + +import java.time.LocalDateTime; +import java.time.LocalTime; + +/** + * 대여품의 반납 정책. + * + * @param maxRentalDays 대여일부터 최대 대여 가능 일수. 0이면 당일 반납 + * @param returnDeadline 반납 마감 시각 + */ +public record ReturnPolicy(int maxRentalDays, LocalTime returnDeadline) { + + /** + * 대여 시각으로부터 반납 기한 시각을 계산한다. 대여한 날짜에 최대 대여 일수를 더한 날의 마감 시각이다. + */ + public LocalDateTime dueAt(LocalDateTime rentAt) { + return rentAt.toLocalDate().plusDays(maxRentalDays).atTime(returnDeadline); + } +} diff --git a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/welfare/ItemJpaEntity.java b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/welfare/ItemJpaEntity.java index ce460bb4..9468f024 100644 --- a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/welfare/ItemJpaEntity.java +++ b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/welfare/ItemJpaEntity.java @@ -7,15 +7,22 @@ import jakarta.persistence.GeneratedValue; import jakarta.persistence.GenerationType; import jakarta.persistence.Id; +import jakarta.persistence.Index; import jakarta.persistence.Table; +import java.time.LocalTime; import kr.ac.kookmin.stream.db.common.BaseTimeEntity; 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.ItemType; +import kr.ac.kookmin.stream.welfare.domain.rental.domain.ReturnPolicy; import lombok.AccessLevel; import lombok.NoArgsConstructor; @Entity -@Table(name = "items") +@Table( + name = "items", + indexes = @Index(name = "idx_items_name", columnList = "name") +) @NoArgsConstructor(access = AccessLevel.PROTECTED) public class ItemJpaEntity extends BaseTimeEntity { @@ -26,6 +33,10 @@ public class ItemJpaEntity extends BaseTimeEntity { @Column(nullable = false) private String name; + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 30) + private ItemCategory category; + @Enumerated(EnumType.STRING) @Column(nullable = false, length = 30) private ItemType type; @@ -36,12 +47,22 @@ public class ItemJpaEntity extends BaseTimeEntity { @Column(name = "image_key") private String imageKey; + @Column(name = "max_rental_days") + private Integer maxRentalDays; + + @Column(name = "return_deadline") + private LocalTime returnDeadline; + private ItemJpaEntity(Item item) { this.id = item.getId(); this.name = item.getName(); + this.category = item.getCategory(); this.type = item.getType(); this.count = item.getCount(); this.imageKey = item.getImageKey(); + ReturnPolicy returnPolicy = item.getReturnPolicy(); + this.maxRentalDays = returnPolicy == null ? null : returnPolicy.maxRentalDays(); + this.returnDeadline = returnPolicy == null ? null : returnPolicy.returnDeadline(); } public static ItemJpaEntity from(Item item) { @@ -49,6 +70,10 @@ public static ItemJpaEntity from(Item item) { } public Item toDomain() { - return Item.of(id, name, type, count, imageKey); + // 두 값이 모두 채워져야 정책이 성립한다 + ReturnPolicy returnPolicy = maxRentalDays == null || returnDeadline == null + ? null + : new ReturnPolicy(maxRentalDays, returnDeadline); + return Item.of(id, name, category, type, count, imageKey, returnPolicy); } } diff --git a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/welfare/RentalHistoryJpaEntity.java b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/welfare/RentalHistoryJpaEntity.java index 2cb8bd29..ae6ddb95 100644 --- a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/welfare/RentalHistoryJpaEntity.java +++ b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/welfare/RentalHistoryJpaEntity.java @@ -20,7 +20,7 @@ @Table( name = "rental_histories", indexes = { - @Index(name = "idx_rental_histories_member_id", columnList = "member_id"), + @Index(name = "idx_rental_histories_member_id_applied_at", columnList = "member_id, applied_at"), @Index(name = "idx_rental_histories_item_id", columnList = "item_id") } ) diff --git a/infrastructure/db/src/main/resources/db/migration/V10__add_item_category_and_return_policy.sql b/infrastructure/db/src/main/resources/db/migration/V10__add_item_category_and_return_policy.sql new file mode 100644 index 00000000..97ec02a3 --- /dev/null +++ b/infrastructure/db/src/main/resources/db/migration/V10__add_item_category_and_return_policy.sql @@ -0,0 +1,31 @@ +-- 빌릴게 물품 목록에 카테고리 필터와 대여품 반납 정책을 추가한다. +-- +-- category: 기존 행이 있을 수 있어 DEFAULT로 채운 뒤 DEFAULT를 제거한다(flyway-migration.md 3-3절). +-- DEFAULT를 남기면 INSERT가 category를 빠뜨려도 조용히 DAILY_SUPPLIES로 들어가므로, 이후엔 항상 명시하게 한다. +-- ⚠️ 기존 items 행의 카테고리는 실제 분류가 아니라 임시값이다. 물품 등록·수정 API가 아직 없어 +-- 운영진이 DB에서 직접 바로잡아야 한다. 적용 전 SELECT COUNT(*) FROM items 로 행 유무를 확인한다. +-- max_rental_days / return_deadline: 대여품(RENTAL)에만 있는 값이라 NULL을 허용한다. +-- 대여일부터 최대 대여 가능 일수(0이면 당일 반납)와 반납 마감 시각(KST 기준 시각)이다. +-- ⚠️ 기존 RENTAL 행은 NULL로 남으므로 반납 마감이 계산되지 않는다. 마찬가지로 직접 채워야 한다. +ALTER TABLE items + ADD COLUMN category VARCHAR(30) NOT NULL DEFAULT 'DAILY_SUPPLIES' AFTER name, -- ELECTRONICS / DAILY_SUPPLIES / MEDICINE / HYGIENE + ADD COLUMN max_rental_days INT NULL AFTER image_key, + ADD COLUMN return_deadline TIME NULL AFTER max_rental_days; + +ALTER TABLE items + ALTER COLUMN category DROP DEFAULT; + +-- 물품 목록은 WHERE (category = ?) AND (name LIKE ?) ORDER BY name, id 로 조회한다. +-- category는 값이 4개뿐인 선택 필터라 앞에 두면 category 없는 조회에서 정렬을 못 받쳐 filesort가 생긴다(V6과 같은 이유). +-- 그래서 정렬 키인 name만 인덱스로 두고, InnoDB가 세컨더리 인덱스 끝에 PK(id)를 붙이므로 (name, id) 정렬이 커버된다. +-- keyword는 부분 일치(LIKE '%..%')라 인덱스를 못 타지만, 물품 수가 적어 name 인덱스 순서 스캔 + 필터로 충분하다. +CREATE INDEX idx_items_name ON items (name); + +-- 내 대여 이력은 항상 WHERE member_id = ? [AND rental_status = ?] ORDER BY applied_at DESC, history_id DESC 로 조회한다. +-- 필터 컬럼을 앞, 정렬 컬럼을 뒤에 둔 복합 인덱스로 바꾼다(V7과 같은 방식). +-- 기존 idx_rental_histories_member_id는 새 인덱스의 좌측 prefix라 완전히 포함되므로 제거한다. +-- rental_status는 한 회원의 이력 안에서 거르는 조건이라 인덱스에 넣지 않는다(회원당 이력이 적다). +DROP INDEX idx_rental_histories_member_id ON rental_histories; + +CREATE INDEX idx_rental_histories_member_id_applied_at + ON rental_histories (member_id, applied_at); From 07622112a6cbd833fdb15a86209b962ae5486562 Mon Sep 17 00:00:00 2001 From: jjunh33 Date: Sun, 27 Sep 2026 03:41:49 +0900 Subject: [PATCH 2/8] =?UTF-8?q?feat:=20=EB=AC=BC=ED=92=88=C2=B7=EB=8C=80?= =?UTF-8?q?=EC=97=AC=20=EC=9D=B4=EB=A0=A5=20=EC=A1=B0=ED=9A=8C=20=EC=84=9C?= =?UTF-8?q?=EB=B9=84=EC=8A=A4=EC=99=80=20=EB=A0=88=ED=8F=AC=EC=A7=80?= =?UTF-8?q?=ED=86=A0=EB=A6=AC=20=ED=8F=AC=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/rental/domain/ItemCursor.java | 34 ++++++++++ .../domain/rental/domain/RentalErrorCode.java | 18 +++++ .../rental/domain/RentalHistorySummary.java | 31 +++++++++ .../domain/rental/domain/RentalStatus.java | 16 ++++- .../rental/domain/ReturnRequiredRental.java | 27 ++++++++ .../rental/repository/ItemRepository.java | 22 +++++++ .../repository/RentalHistoryRepository.java | 15 +++++ .../domain/rental/service/ItemService.java | 18 +++++ .../rental/service/RentalHistoryService.java | 22 +++++++ .../rental/service/impl/ItemServiceImpl.java | 23 +++++++ .../impl/RentalHistoryServiceImpl.java | 65 +++++++++++++++++++ 11 files changed, 290 insertions(+), 1 deletion(-) create mode 100644 core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/rental/domain/ItemCursor.java create mode 100644 core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/rental/domain/RentalErrorCode.java create mode 100644 core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/rental/domain/RentalHistorySummary.java create mode 100644 core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/rental/domain/ReturnRequiredRental.java create mode 100644 core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/rental/repository/ItemRepository.java create mode 100644 core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/rental/repository/RentalHistoryRepository.java create mode 100644 core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/rental/service/ItemService.java create mode 100644 core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/rental/service/RentalHistoryService.java create mode 100644 core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/rental/service/impl/ItemServiceImpl.java create mode 100644 core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/rental/service/impl/RentalHistoryServiceImpl.java diff --git a/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/rental/domain/ItemCursor.java b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/rental/domain/ItemCursor.java new file mode 100644 index 00000000..c283df36 --- /dev/null +++ b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/rental/domain/ItemCursor.java @@ -0,0 +1,34 @@ +package kr.ac.kookmin.stream.welfare.domain.rental.domain; + +import kr.ac.kookmin.stream.common.BusinessException; +import kr.ac.kookmin.stream.common.Cursor; + +/** + * 물품 목록의 keyset 커서. 정렬 기준(이름 오름차순 + itemId 오름차순)과 짝을 이룬다. + */ +public record ItemCursor(String name, Long id) implements Cursor { + + private static final char JOIN = '|'; + + public static ItemCursor of(Item item) { + return new ItemCursor(item.getName(), item.getId()); + } + + // 이름에 구분자가 들어 있어도 되도록 id가 오는 마지막 구분자를 기준으로 나눈다(id는 숫자라 구분자를 포함하지 않는다) + public static ItemCursor from(String raw) { + int separator = raw.lastIndexOf(JOIN); + if (separator < 0) { + throw new BusinessException(RentalErrorCode.ITEM_INVALID_CURSOR); + } + try { + return new ItemCursor(raw.substring(0, separator), Long.valueOf(raw.substring(separator + 1))); + } catch (NumberFormatException e) { + throw new BusinessException(RentalErrorCode.ITEM_INVALID_CURSOR); + } + } + + @Override + public String format() { + return name + JOIN + id; + } +} 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 new file mode 100644 index 00000000..23b0ace6 --- /dev/null +++ b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/rental/domain/RentalErrorCode.java @@ -0,0 +1,18 @@ +package kr.ac.kookmin.stream.welfare.domain.rental.domain; + +import kr.ac.kookmin.stream.common.ErrorCode; +import kr.ac.kookmin.stream.common.ErrorStatus; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.experimental.Accessors; + +@Getter +@Accessors(fluent = true) +@AllArgsConstructor +public enum RentalErrorCode implements ErrorCode { + + ITEM_INVALID_CURSOR(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/rental/domain/RentalHistorySummary.java b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/rental/domain/RentalHistorySummary.java new file mode 100644 index 00000000..9a55afeb --- /dev/null +++ b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/rental/domain/RentalHistorySummary.java @@ -0,0 +1,31 @@ +package kr.ac.kookmin.stream.welfare.domain.rental.domain; + +import java.time.LocalDateTime; + +/** + * 내 대여 이력 목록 한 건. 이력에 물품 이름·이미지를 붙인 읽기 모델이다. + * 물품이 없으면(참조 무결성은 DB가 아닌 애플리케이션이 관리한다) 이름·이미지는 null이다. + */ +public record RentalHistorySummary( + Long historyId, + String itemName, + String itemImageKey, + LocalDateTime rentAt, + LocalDateTime returnedAt, + RentalStatus status +) { + + /** + * @param item 이력의 물품. 물품이 사라졌으면 {@code null} + */ + public static RentalHistorySummary of(RentalHistory history, Item item) { + return new RentalHistorySummary( + history.getId(), + item == null ? null : item.getName(), + item == null ? null : item.getImageKey(), + history.getRentAt(), + history.getReturnedAt(), + history.getRentalStatus() + ); + } +} diff --git a/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/rental/domain/RentalStatus.java b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/rental/domain/RentalStatus.java index 0e9c4ed1..f93dca1a 100644 --- a/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/rental/domain/RentalStatus.java +++ b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/rental/domain/RentalStatus.java @@ -1,5 +1,8 @@ package kr.ac.kookmin.stream.welfare.domain.rental.domain; +import kr.ac.kookmin.stream.common.BusinessException; +import kr.ac.kookmin.stream.common.CommonErrorCode; + public enum RentalStatus { PENDING, CONFIRMED, @@ -8,5 +11,16 @@ public enum RentalStatus { RENTAL, RETURN_PENDING, RETURNED, - RETURN_CONFIRMED + RETURN_CONFIRMED; + + public static RentalStatus from(String value) { + if (value == null) { + return null; + } + try { + return RentalStatus.valueOf(value); + } catch (IllegalArgumentException e) { + throw new BusinessException(CommonErrorCode.INVALID_INPUT); + } + } } diff --git a/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/rental/domain/ReturnRequiredRental.java b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/rental/domain/ReturnRequiredRental.java new file mode 100644 index 00000000..7279989d --- /dev/null +++ b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/rental/domain/ReturnRequiredRental.java @@ -0,0 +1,27 @@ +package kr.ac.kookmin.stream.welfare.domain.rental.domain; + +import java.time.LocalDateTime; + +/** + * 반납해야 하는 대여 한 건. 대여 시각과 물품의 반납 정책으로 계산한 반납 기한을 함께 담는다. + */ +public record ReturnRequiredRental( + Long historyId, + String itemName, + String itemImageKey, + LocalDateTime dueAt +) { + + /** + * @param history 대여 시각(rentAt)이 있는 이력 + * @param item 반납 정책이 있는 물품 + */ + public static ReturnRequiredRental of(RentalHistory history, Item item) { + return new ReturnRequiredRental( + history.getId(), + item.getName(), + item.getImageKey(), + item.getReturnPolicy().dueAt(history.getRentAt()) + ); + } +} 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 new file mode 100644 index 00000000..9747cf48 --- /dev/null +++ b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/rental/repository/ItemRepository.java @@ -0,0 +1,22 @@ +package kr.ac.kookmin.stream.welfare.domain.rental.repository; + +import java.util.Collection; +import java.util.List; +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; + +public interface ItemRepository { + + /** + * 물품을 이름 오름차순(같으면 식별자 오름차순)으로 커서 페이지 단위로 조회한다. + * + * @param category 카테고리. null이면 전체 + * @param keyword 이름에 포함된 검색어. null이면 검색하지 않는다 + * @param cursor 이전 페이지의 마지막 항목. null이면 첫 페이지 + */ + CursorSliceResult findSlice(ItemCategory category, String keyword, ItemCursor cursor, int size); + + List findAllByIds(Collection ids); +} 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 new file mode 100644 index 00000000..33721bb9 --- /dev/null +++ b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/rental/repository/RentalHistoryRepository.java @@ -0,0 +1,15 @@ +package kr.ac.kookmin.stream.welfare.domain.rental.repository; + +import java.util.List; +import kr.ac.kookmin.stream.welfare.domain.rental.domain.RentalHistory; +import kr.ac.kookmin.stream.welfare.domain.rental.domain.RentalStatus; + +public interface RentalHistoryRepository { + + /** + * 회원의 대여 이력을 신청 시각 내림차순(같으면 식별자 내림차순)으로 조회한다. + * + * @param status 대여 상태. null이면 전체 + */ + List findAllByMemberId(Long memberId, RentalStatus status); +} 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 new file mode 100644 index 00000000..63448314 --- /dev/null +++ b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/rental/service/ItemService.java @@ -0,0 +1,18 @@ +package kr.ac.kookmin.stream.welfare.domain.rental.service; + +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; + +public interface ItemService { + + /** + * 물품 목록을 이름순 커서 페이지로 조회한다. + * + * @param category 카테고리. null이면 전체 + * @param keyword 이름 검색어. null이면 검색하지 않는다 + * @param cursor 이전 페이지의 마지막 항목. null이면 첫 페이지 + */ + CursorSliceResult getItems(ItemCategory category, String keyword, ItemCursor cursor, int size); +} 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 new file mode 100644 index 00000000..20aaac34 --- /dev/null +++ b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/rental/service/RentalHistoryService.java @@ -0,0 +1,22 @@ +package kr.ac.kookmin.stream.welfare.domain.rental.service; + +import java.util.List; +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; + +public interface RentalHistoryService { + + /** + * 회원의 대여 이력을 최근 신청순으로 조회한다. + * + * @param status 대여 상태. null이면 전체 + */ + List getHistories(Long memberId, RentalStatus status); + + /** + * 회원이 지금 반납해야 하는 대여(대여 중 상태)와 반납 기한을 조회한다. + * 반납 정책이 없는 물품의 대여는 기한을 계산할 수 없어 제외한다. + */ + List getReturnRequiredRentals(Long memberId); +} 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 new file mode 100644 index 00000000..88aa630f --- /dev/null +++ b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/rental/service/impl/ItemServiceImpl.java @@ -0,0 +1,23 @@ +package kr.ac.kookmin.stream.welfare.domain.rental.service.impl; + +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.repository.ItemRepository; +import kr.ac.kookmin.stream.welfare.domain.rental.service.ItemService; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +class ItemServiceImpl implements ItemService { + + private final ItemRepository itemRepository; + + // 조회 쿼리가 1개라 트랜잭션을 걸지 않는다. 쿼리가 늘어 한 스냅샷이 필요해지면 @Transactional(readOnly = true)를 붙인다. + @Override + public CursorSliceResult getItems(ItemCategory category, String keyword, ItemCursor cursor, int size) { + return itemRepository.findSlice(category, keyword, cursor, size); + } +} 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 new file mode 100644 index 00000000..327ec3f4 --- /dev/null +++ b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/rental/service/impl/RentalHistoryServiceImpl.java @@ -0,0 +1,65 @@ +package kr.ac.kookmin.stream.welfare.domain.rental.service.impl; + +import java.util.List; +import java.util.Map; +import java.util.function.Function; +import java.util.stream.Collectors; +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; +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.RentalHistoryService; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +class RentalHistoryServiceImpl implements RentalHistoryService { + + private final RentalHistoryRepository rentalHistoryRepository; + private final ItemRepository itemRepository; + + // 이력과 물품을 두 번에 나눠 읽어 서비스가 짝짓는다. 두 조회가 한 트랜잭션(같은 스냅샷)에 묶여야 + // 그 사이에 바뀐 물품 때문에 이력과 물품 정보가 어긋나지 않는다. 쓰기가 없으니 readOnly다. + @Override + @Transactional(readOnly = true) + public List getHistories(Long memberId, RentalStatus status) { + List histories = rentalHistoryRepository.findAllByMemberId(memberId, status); + Map items = findItemsOf(histories); + + return histories.stream() + .map(history -> RentalHistorySummary.of(history, items.get(history.getItemId()))) + .toList(); + } + + // getHistories와 같은 이유(이력 + 물품 두 조회의 스냅샷 일관성, 쓰기 없음)로 readOnly다. + @Override + @Transactional(readOnly = true) + public List getReturnRequiredRentals(Long memberId) { + List histories = rentalHistoryRepository.findAllByMemberId(memberId, RentalStatus.RENTAL); + Map items = findItemsOf(histories); + + return histories.stream() + .filter(history -> hasDueAt(history, items.get(history.getItemId()))) + .map(history -> ReturnRequiredRental.of(history, items.get(history.getItemId()))) + .toList(); + } + + private Map findItemsOf(List histories) { + if (histories.isEmpty()) { + return Map.of(); + } + List itemIds = histories.stream().map(RentalHistory::getItemId).distinct().toList(); + return itemRepository.findAllByIds(itemIds).stream() + .collect(Collectors.toMap(Item::getId, Function.identity())); + } + + // 대여 시각과 물품의 반납 정책이 모두 있어야 반납 기한을 계산할 수 있다 + private boolean hasDueAt(RentalHistory history, Item item) { + return history.getRentAt() != null && item != null && item.getReturnPolicy() != null; + } +} From 480c2b3093b87626c38995939aa7c6f134a55047 Mon Sep 17 00:00:00 2001 From: jjunh33 Date: Sun, 27 Sep 2026 03:41:49 +0900 Subject: [PATCH 3/8] =?UTF-8?q?feat:=20=EB=AC=BC=ED=92=88=C2=B7=EB=8C=80?= =?UTF-8?q?=EC=97=AC=20=EC=9D=B4=EB=A0=A5=20=EB=A0=88=ED=8F=AC=EC=A7=80?= =?UTF-8?q?=ED=86=A0=EB=A6=AC=20=EA=B5=AC=ED=98=84=EC=B2=B4=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 --- .../stream/db/welfare/ItemJpaRepository.java | 40 +++++++++++++++ .../stream/db/welfare/ItemRepositoryImpl.java | 50 +++++++++++++++++++ .../welfare/RentalHistoryJpaRepository.java | 21 ++++++++ .../welfare/RentalHistoryRepositoryImpl.java | 22 ++++++++ 4 files changed, 133 insertions(+) create mode 100644 infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/welfare/ItemJpaRepository.java create mode 100644 infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/welfare/ItemRepositoryImpl.java create mode 100644 infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/welfare/RentalHistoryJpaRepository.java create mode 100644 infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/welfare/RentalHistoryRepositoryImpl.java 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 new file mode 100644 index 00000000..f1432b8f --- /dev/null +++ b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/welfare/ItemJpaRepository.java @@ -0,0 +1,40 @@ +package kr.ac.kookmin.stream.db.welfare; + +import java.util.List; +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.Query; +import org.springframework.data.repository.query.Param; + +public interface ItemJpaRepository extends JpaRepository { + + // keywordPattern은 LIKE 패턴이다. 검색어의 %·_를 이스케이프한 뒤 '!'를 이스케이프 문자로 쓴다 + // ('\\'는 MySQL 문자열 리터럴에서 다시 이스케이프되어 쓸 수 없다) + @Query(""" + SELECT i FROM ItemJpaEntity i + WHERE (:category IS NULL OR i.category = :category) + AND (:keywordPattern IS NULL OR i.name LIKE :keywordPattern ESCAPE '!') + ORDER BY i.name ASC, i.id ASC + """) + List findFirstSlice( + @Param("category") ItemCategory category, + @Param("keywordPattern") String keywordPattern, + Pageable pageable + ); + + @Query(""" + SELECT i FROM ItemJpaEntity i + WHERE (:category IS NULL OR i.category = :category) + AND (:keywordPattern IS NULL OR i.name LIKE :keywordPattern ESCAPE '!') + AND (i.name > :cursorName OR (i.name = :cursorName AND i.id > :cursorId)) + ORDER BY i.name ASC, i.id ASC + """) + List findNextSlice( + @Param("category") ItemCategory category, + @Param("keywordPattern") String keywordPattern, + @Param("cursorName") String cursorName, + @Param("cursorId") Long cursorId, + Pageable pageable + ); +} 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 new file mode 100644 index 00000000..3d130e47 --- /dev/null +++ b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/welfare/ItemRepositoryImpl.java @@ -0,0 +1,50 @@ +package kr.ac.kookmin.stream.db.welfare; + +import java.util.Collection; +import java.util.List; +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.repository.ItemRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Repository; + +@Repository +@RequiredArgsConstructor +public class ItemRepositoryImpl implements ItemRepository { + + private static final char LIKE_ESCAPE = '!'; + + private final ItemJpaRepository itemJpaRepository; + + @Override + public CursorSliceResult findSlice(ItemCategory category, String keyword, ItemCursor cursor, int size) { + Pageable pageable = Pageable.ofSize(size + 1); + String keywordPattern = keyword == null ? null : toContainsPattern(keyword); + List entities = cursor == null + ? itemJpaRepository.findFirstSlice(category, keywordPattern, pageable) + : itemJpaRepository.findNextSlice(category, keywordPattern, cursor.name(), cursor.id(), pageable); + + return CursorSliceResult.ofSlice( + entities, size, ItemJpaEntity::toDomain, item -> ItemCursor.of(item).format() + ); + } + + @Override + public List findAllByIds(Collection ids) { + return itemJpaRepository.findAllById(ids).stream() + .map(ItemJpaEntity::toDomain) + .toList(); + } + + // 검색어를 "포함" 조건의 LIKE 패턴으로 바꾼다. 검색어 안의 와일드카드가 패턴으로 해석되지 않게 이스케이프한다 + private String toContainsPattern(String keyword) { + String escaped = keyword + .replace(String.valueOf(LIKE_ESCAPE), "" + LIKE_ESCAPE + LIKE_ESCAPE) + .replace("%", LIKE_ESCAPE + "%") + .replace("_", LIKE_ESCAPE + "_"); + return "%" + escaped + "%"; + } +} 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 new file mode 100644 index 00000000..4d3606e5 --- /dev/null +++ b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/welfare/RentalHistoryJpaRepository.java @@ -0,0 +1,21 @@ +package kr.ac.kookmin.stream.db.welfare; + +import java.util.List; +import kr.ac.kookmin.stream.welfare.domain.rental.domain.RentalStatus; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +public interface RentalHistoryJpaRepository extends JpaRepository { + + @Query(""" + SELECT h FROM RentalHistoryJpaEntity h + WHERE h.memberId = :memberId + AND (:status IS NULL OR h.rentalStatus = :status) + ORDER BY h.appliedAt DESC, h.id DESC + """) + List findAllByMemberId( + @Param("memberId") Long memberId, + @Param("status") RentalStatus status + ); +} 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 new file mode 100644 index 00000000..8dfe1494 --- /dev/null +++ b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/welfare/RentalHistoryRepositoryImpl.java @@ -0,0 +1,22 @@ +package kr.ac.kookmin.stream.db.welfare; + +import java.util.List; +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; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +@Repository +@RequiredArgsConstructor +public class RentalHistoryRepositoryImpl implements RentalHistoryRepository { + + private final RentalHistoryJpaRepository rentalHistoryJpaRepository; + + @Override + public List findAllByMemberId(Long memberId, RentalStatus status) { + return rentalHistoryJpaRepository.findAllByMemberId(memberId, status).stream() + .map(RentalHistoryJpaEntity::toDomain) + .toList(); + } +} From d4bbe1a01a2c1f878392f42f48b043b73ff7c887 Mon Sep 17 00:00:00 2001 From: jjunh33 Date: Sun, 27 Sep 2026 03:41:50 +0900 Subject: [PATCH 4/8] =?UTF-8?q?feat:=20=ED=95=99=EC=83=9D=20=EC=95=B1=20?= =?UTF-8?q?=EB=B9=8C=EB=A6=B4=EA=B2=8C=20=EB=AC=BC=ED=92=88=C2=B7=EB=8C=80?= =?UTF-8?q?=EC=97=AC=20=EC=9D=B4=EB=A0=A5=20=EC=A1=B0=ED=9A=8C=20API=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../api/app/welfare/rental/AppRentalApi.java | 49 +++++++++++++++ .../welfare/rental/AppRentalController.java | 59 +++++++++++++++++++ .../rental/request/ItemListParams.java | 39 ++++++++++++ .../request/RentalHistoryListParams.java | 10 ++++ .../welfare/rental/response/ItemImageUrl.java | 17 ++++++ .../rental/response/ItemListItemResponse.java | 43 ++++++++++++++ .../response/RentalHistoryListResponse.java | 34 +++++++++++ .../response/ReturnRequiredListResponse.java | 29 +++++++++ 8 files changed, 280 insertions(+) create mode 100644 api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/rental/AppRentalApi.java create mode 100644 api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/rental/AppRentalController.java create mode 100644 api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/rental/request/ItemListParams.java create mode 100644 api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/rental/request/RentalHistoryListParams.java create mode 100644 api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/rental/response/ItemImageUrl.java create mode 100644 api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/rental/response/ItemListItemResponse.java create mode 100644 api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/rental/response/RentalHistoryListResponse.java create mode 100644 api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/rental/response/ReturnRequiredListResponse.java 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 new file mode 100644 index 00000000..f5b828c6 --- /dev/null +++ b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/rental/AppRentalApi.java @@ -0,0 +1,49 @@ +package kr.ac.kookmin.stream.api.app.welfare.rental; + +import io.swagger.v3.oas.annotations.Operation; +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.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.common.dto.ApiResponse; +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.rental.domain.RentalErrorCode; +import org.springdoc.core.annotations.ParameterObject; + +/** + * 학생 앱 빌릴게(물품 대여) API의 문서 명세. 구현은 {@link AppRentalController}가 맡는다. + *

+ * 스웨거 문서용 어노테이션만 이쪽에 두고 컨트롤러에는 라우팅과 본문만 남긴다. 경로 매핑과 + * 파라미터 바인딩(@{@code ModelAttribute} 등)은 구현체에 둔다. + */ +@Tag(name = "빌릴게", description = "학생 앱 빌릴게 물품·대여 이력 조회") +public interface AppRentalApi { + + /** 물품 목록. 이름순 커서 페이지네이션이며 카테고리·검색어로 거른다. */ + @Operation(summary = "물품 목록 조회", + description = "물품을 이름순으로 커서 기반 조회한다. category로 분류를, keyword로 이름 검색을 거르고 " + + "cursor/size로 다음 페이지를 넘긴다. 반납 정책(returnPolicy)은 대여품(RENTAL)에만 내려가고 소모품은 null이다.") + @ApiErrorCode(type = CommonErrorCode.class, codes = {"INVALID_INPUT"}) + @ApiErrorCode(type = RentalErrorCode.class, codes = {"ITEM_INVALID_CURSOR"}) + ApiResponse> getItems(@ParameterObject ItemListParams params); + + /** 내 대여 이력. 최근 신청순이며 상태로 거른다. */ + @Operation(summary = "내 대여 이력 조회", + description = "내 대여 이력을 최근 신청순으로 조회한다. status를 생략하면 전체 상태를 조회한다.") + @ApiErrorCode(type = CommonErrorCode.class, codes = {"INVALID_INPUT"}) + ApiResponse getHistories( + AppApiUser apiUser, + @ParameterObject RentalHistoryListParams params + ); + + /** 지금 반납해야 하는 대여와 반납 기한. */ + @Operation(summary = "반납 필요 대여 조회", + description = "지금 반납해야 하는 대여(대여 중 상태)와 반납 기한(dueAt)을 조회한다. " + + "반납 기한은 대여일에 최대 대여 일수를 더한 날의 반납 마감 시각이다. 없으면 빈 배열이다.") + ApiResponse getReturnRequired(AppApiUser apiUser); +} 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 new file mode 100644 index 00000000..974387ba --- /dev/null +++ b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/rental/AppRentalController.java @@ -0,0 +1,59 @@ +package kr.ac.kookmin.stream.api.app.welfare.rental; + +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.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.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 lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.ModelAttribute; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/v1/app/billilge") +@RequiredArgsConstructor +public class AppRentalController implements AppRentalApi { + + private final ItemService itemService; + private final RentalHistoryService rentalHistoryService; + + @Override + @GetMapping("/items") + public ApiResponse> getItems( + @Valid @ModelAttribute ItemListParams params + ) { + CursorSliceResult result = itemService.getItems( + params.toCategory(), params.toKeyword(), params.toCursor(), params.sizeOrDefault() + ); + return ApiResponse.success(CursorSliceResponse.from(result, ItemListItemResponse::from)); + } + + @Override + @GetMapping("/histories") + public ApiResponse getHistories( + AppApiUser apiUser, + @Valid @ModelAttribute RentalHistoryListParams params + ) { + return ApiResponse.success(RentalHistoryListResponse.from( + rentalHistoryService.getHistories(apiUser.userId(), params.toStatus()) + )); + } + + @Override + @GetMapping("/histories/return-required") + public ApiResponse getReturnRequired(AppApiUser apiUser) { + return ApiResponse.success(ReturnRequiredListResponse.from( + rentalHistoryService.getReturnRequiredRentals(apiUser.userId()) + )); + } +} diff --git a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/rental/request/ItemListParams.java b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/rental/request/ItemListParams.java new file mode 100644 index 00000000..2792022c --- /dev/null +++ b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/rental/request/ItemListParams.java @@ -0,0 +1,39 @@ +package kr.ac.kookmin.stream.api.app.welfare.rental.request; + +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import kr.ac.kookmin.stream.api.common.CursorCodec; +import kr.ac.kookmin.stream.welfare.domain.rental.domain.ItemCategory; +import kr.ac.kookmin.stream.welfare.domain.rental.domain.ItemCursor; + +public record ItemListParams( + String category, + + String keyword, + + String cursor, + + @Min(value = 1, message = "조회 개수는 1 이상 100 이하여야 합니다.") + @Max(value = 100, message = "조회 개수는 1 이상 100 이하여야 합니다.") + Integer size +) { + + private static final int DEFAULT_SIZE = 20; + + public ItemCategory toCategory() { + return ItemCategory.from(category); + } + + // 공백뿐인 검색어는 검색하지 않는 것과 같다 + public String toKeyword() { + return keyword == null || keyword.isBlank() ? null : keyword.strip(); + } + + public ItemCursor toCursor() { + return cursor == null ? null : ItemCursor.from(CursorCodec.decode(cursor)); + } + + public int sizeOrDefault() { + return size == null ? DEFAULT_SIZE : size; + } +} diff --git a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/rental/request/RentalHistoryListParams.java b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/rental/request/RentalHistoryListParams.java new file mode 100644 index 00000000..bf704a72 --- /dev/null +++ b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/rental/request/RentalHistoryListParams.java @@ -0,0 +1,10 @@ +package kr.ac.kookmin.stream.api.app.welfare.rental.request; + +import kr.ac.kookmin.stream.welfare.domain.rental.domain.RentalStatus; + +public record RentalHistoryListParams(String status) { + + public RentalStatus toStatus() { + return RentalStatus.from(status); + } +} diff --git a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/rental/response/ItemImageUrl.java b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/rental/response/ItemImageUrl.java new file mode 100644 index 00000000..a56ff3fe --- /dev/null +++ b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/rental/response/ItemImageUrl.java @@ -0,0 +1,17 @@ +package kr.ac.kookmin.stream.api.app.welfare.rental.response; + +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +/** + * 물품 이미지 파일 키를 공개 URL로 바꾸는 지점. + *

+ * 파일 키 → 공개 URL 조립이 아직 없어 현재는 항상 null이다. 조립이 생기면 이 메서드만 채우면 된다. + */ +@NoArgsConstructor(access = AccessLevel.PRIVATE) +final class ItemImageUrl { + + static String from(String imageKey) { + return null; + } +} diff --git a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/rental/response/ItemListItemResponse.java b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/rental/response/ItemListItemResponse.java new file mode 100644 index 00000000..77f03b2c --- /dev/null +++ b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/rental/response/ItemListItemResponse.java @@ -0,0 +1,43 @@ +package kr.ac.kookmin.stream.api.app.welfare.rental.response; + +import java.time.format.DateTimeFormatter; +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.ItemType; +import kr.ac.kookmin.stream.welfare.domain.rental.domain.ReturnPolicy; + +public record ItemListItemResponse( + Long itemId, + String name, + ItemCategory category, + ItemType type, + int count, + String imageUrl, + ReturnPolicyResponse returnPolicy +) { + + public static ItemListItemResponse from(Item item) { + ReturnPolicy returnPolicy = item.getReturnPolicy(); + return new ItemListItemResponse( + item.getId(), + item.getName(), + item.getCategory(), + item.getType(), + item.getCount(), + ItemImageUrl.from(item.getImageKey()), + returnPolicy == null ? null : ReturnPolicyResponse.from(returnPolicy) + ); + } + + public record ReturnPolicyResponse(int maxRentalDays, String returnDeadline) { + + private static final DateTimeFormatter DEADLINE_FORMAT = DateTimeFormatter.ofPattern("HH:mm"); + + public static ReturnPolicyResponse from(ReturnPolicy returnPolicy) { + return new ReturnPolicyResponse( + returnPolicy.maxRentalDays(), + returnPolicy.returnDeadline().format(DEADLINE_FORMAT) + ); + } + } +} diff --git a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/rental/response/RentalHistoryListResponse.java b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/rental/response/RentalHistoryListResponse.java new file mode 100644 index 00000000..955ca301 --- /dev/null +++ b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/rental/response/RentalHistoryListResponse.java @@ -0,0 +1,34 @@ +package kr.ac.kookmin.stream.api.app.welfare.rental.response; + +import java.time.LocalDateTime; +import java.util.List; +import kr.ac.kookmin.stream.welfare.domain.rental.domain.RentalHistorySummary; +import kr.ac.kookmin.stream.welfare.domain.rental.domain.RentalStatus; + +public record RentalHistoryListResponse(List histories) { + + public static RentalHistoryListResponse from(List summaries) { + return new RentalHistoryListResponse(summaries.stream().map(History::from).toList()); + } + + public record History( + Long rentalHistoryId, + String itemName, + String itemImageUrl, + LocalDateTime rentAt, + LocalDateTime returnedAt, + RentalStatus status + ) { + + public static History from(RentalHistorySummary summary) { + return new History( + summary.historyId(), + summary.itemName(), + ItemImageUrl.from(summary.itemImageKey()), + summary.rentAt(), + summary.returnedAt(), + summary.status() + ); + } + } +} diff --git a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/rental/response/ReturnRequiredListResponse.java b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/rental/response/ReturnRequiredListResponse.java new file mode 100644 index 00000000..493fbcae --- /dev/null +++ b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/rental/response/ReturnRequiredListResponse.java @@ -0,0 +1,29 @@ +package kr.ac.kookmin.stream.api.app.welfare.rental.response; + +import java.time.LocalDateTime; +import java.util.List; +import kr.ac.kookmin.stream.welfare.domain.rental.domain.ReturnRequiredRental; + +public record ReturnRequiredListResponse(List rentalHistories) { + + public static ReturnRequiredListResponse from(List rentals) { + return new ReturnRequiredListResponse(rentals.stream().map(Rental::from).toList()); + } + + public record Rental( + Long rentalHistoryId, + String itemName, + String itemImageUrl, + LocalDateTime dueAt + ) { + + public static Rental from(ReturnRequiredRental rental) { + return new Rental( + rental.historyId(), + rental.itemName(), + ItemImageUrl.from(rental.itemImageKey()), + rental.dueAt() + ); + } + } +} From 6061f0dd15d3b2e2565c041348266b9f7948e666 Mon Sep 17 00:00:00 2001 From: jjunh33 Date: Mon, 28 Sep 2026 11:11:29 +0900 Subject: [PATCH 5/8] =?UTF-8?q?refactor:=20=EB=8C=80=EC=97=AC=20=EC=9D=B4?= =?UTF-8?q?=EB=A0=A5=20=EC=A1=B0=ED=9A=8C=202=EA=B1=B4=EC=97=90=EC=84=9C?= =?UTF-8?q?=20=EB=B6=88=ED=95=84=EC=9A=94=ED=95=9C=20readOnly=20=ED=8A=B8?= =?UTF-8?q?=EB=9E=9C=EC=9E=AD=EC=85=98=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/impl/RentalHistoryServiceImpl.java | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) 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..410fa2d1 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 @@ -14,7 +14,6 @@ import kr.ac.kookmin.stream.welfare.domain.rental.service.RentalHistoryService; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; @Service @RequiredArgsConstructor @@ -23,10 +22,11 @@ class RentalHistoryServiceImpl implements RentalHistoryService { private final RentalHistoryRepository rentalHistoryRepository; private final ItemRepository itemRepository; - // 이력과 물품을 두 번에 나눠 읽어 서비스가 짝짓는다. 두 조회가 한 트랜잭션(같은 스냅샷)에 묶여야 - // 그 사이에 바뀐 물품 때문에 이력과 물품 정보가 어긋나지 않는다. 쓰기가 없으니 readOnly다. + // 이력과 물품을 두 번에 나눠 읽어 서비스가 짝짓는다(coding-style.md 2-6). 지금은 items에 쓰기 경로가 + // 없어 두 조회 사이에 물품이 바뀔 수 없으므로 트랜잭션이 필요 없다. 물품 이름 수정·신규 등록 정도만 + // 생기는 한 이 필드는 필터·계산에 안 쓰여 트랜잭션 없이도 안전하다. 반납 정책·타입처럼 hasDueAt·dueAt + // 계산에 쓰이는 필드를 수정하는 기능이 생기면 그때 @Transactional(readOnly = true)를 다시 붙인다. @Override - @Transactional(readOnly = true) public List getHistories(Long memberId, RentalStatus status) { List histories = rentalHistoryRepository.findAllByMemberId(memberId, status); Map items = findItemsOf(histories); @@ -36,9 +36,9 @@ public List getHistories(Long memberId, RentalStatus statu .toList(); } - // getHistories와 같은 이유(이력 + 물품 두 조회의 스냅샷 일관성, 쓰기 없음)로 readOnly다. + // getHistories와 같은 이유로 트랜잭션이 필요 없다. hasDueAt이 보는 returnPolicy는 지금 계획된 + // 쓰기(이름 수정·신규 등록)로는 바뀌지 않으므로, 두 조회 사이에 반납 필요 여부가 달라지지 않는다. @Override - @Transactional(readOnly = true) public List getReturnRequiredRentals(Long memberId) { List histories = rentalHistoryRepository.findAllByMemberId(memberId, RentalStatus.RENTAL); Map items = findItemsOf(histories); From 99c06734be94854ff76e31935fd30b11c31aa568 Mon Sep 17 00:00:00 2001 From: jjunh33 Date: Mon, 28 Sep 2026 16:07:28 +0900 Subject: [PATCH 6/8] =?UTF-8?q?refactor:=20=EC=9D=91=EB=8B=B5=EC=9D=84=20?= =?UTF-8?q?=EB=B3=B8=EB=9C=AC=20=EC=A1=B0=ED=9A=8C=20record=EB=A5=BC=20?= =?UTF-8?q?=EB=8F=84=EB=A9=94=EC=9D=B8=20=EA=B0=9D=EC=B2=B4=EB=A5=BC=20?= =?UTF-8?q?=EB=8B=B4=EB=8A=94=20RentalRecord=EB=A1=9C=20=EA=B5=90=EC=B2=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../response/RentalHistoryListResponse.java | 22 ++++++----- .../response/ReturnRequiredListResponse.java | 16 ++++---- .../rental/domain/RentalHistorySummary.java | 31 ---------------- .../domain/rental/domain/RentalRecord.java | 37 +++++++++++++++++++ .../rental/domain/ReturnRequiredRental.java | 27 -------------- .../rental/service/RentalHistoryService.java | 9 ++--- .../impl/RentalHistoryServiceImpl.java | 34 +++++++---------- 7 files changed, 74 insertions(+), 102 deletions(-) delete mode 100644 core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/rental/domain/RentalHistorySummary.java create mode 100644 core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/rental/domain/RentalRecord.java delete mode 100644 core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/rental/domain/ReturnRequiredRental.java diff --git a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/rental/response/RentalHistoryListResponse.java b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/rental/response/RentalHistoryListResponse.java index 955ca301..77fa4693 100644 --- a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/rental/response/RentalHistoryListResponse.java +++ b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/rental/response/RentalHistoryListResponse.java @@ -2,13 +2,14 @@ import java.time.LocalDateTime; import java.util.List; -import kr.ac.kookmin.stream.welfare.domain.rental.domain.RentalHistorySummary; +import kr.ac.kookmin.stream.welfare.domain.rental.domain.RentalHistory; +import kr.ac.kookmin.stream.welfare.domain.rental.domain.RentalRecord; import kr.ac.kookmin.stream.welfare.domain.rental.domain.RentalStatus; public record RentalHistoryListResponse(List histories) { - public static RentalHistoryListResponse from(List summaries) { - return new RentalHistoryListResponse(summaries.stream().map(History::from).toList()); + public static RentalHistoryListResponse from(List records) { + return new RentalHistoryListResponse(records.stream().map(History::from).toList()); } public record History( @@ -20,14 +21,15 @@ public record History( RentalStatus status ) { - public static History from(RentalHistorySummary summary) { + public static History from(RentalRecord record) { + RentalHistory history = record.history(); return new History( - summary.historyId(), - summary.itemName(), - ItemImageUrl.from(summary.itemImageKey()), - summary.rentAt(), - summary.returnedAt(), - summary.status() + history.getId(), + record.itemName(), + ItemImageUrl.from(record.itemImageKey()), + history.getRentAt(), + history.getReturnedAt(), + history.getRentalStatus() ); } } diff --git a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/rental/response/ReturnRequiredListResponse.java b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/rental/response/ReturnRequiredListResponse.java index 493fbcae..a17b88ad 100644 --- a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/rental/response/ReturnRequiredListResponse.java +++ b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/rental/response/ReturnRequiredListResponse.java @@ -2,12 +2,12 @@ import java.time.LocalDateTime; import java.util.List; -import kr.ac.kookmin.stream.welfare.domain.rental.domain.ReturnRequiredRental; +import kr.ac.kookmin.stream.welfare.domain.rental.domain.RentalRecord; public record ReturnRequiredListResponse(List rentalHistories) { - public static ReturnRequiredListResponse from(List rentals) { - return new ReturnRequiredListResponse(rentals.stream().map(Rental::from).toList()); + public static ReturnRequiredListResponse from(List records) { + return new ReturnRequiredListResponse(records.stream().map(Rental::from).toList()); } public record Rental( @@ -17,12 +17,12 @@ public record Rental( LocalDateTime dueAt ) { - public static Rental from(ReturnRequiredRental rental) { + public static Rental from(RentalRecord record) { return new Rental( - rental.historyId(), - rental.itemName(), - ItemImageUrl.from(rental.itemImageKey()), - rental.dueAt() + record.history().getId(), + record.itemName(), + ItemImageUrl.from(record.itemImageKey()), + record.dueAt() ); } } diff --git a/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/rental/domain/RentalHistorySummary.java b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/rental/domain/RentalHistorySummary.java deleted file mode 100644 index 9a55afeb..00000000 --- a/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/rental/domain/RentalHistorySummary.java +++ /dev/null @@ -1,31 +0,0 @@ -package kr.ac.kookmin.stream.welfare.domain.rental.domain; - -import java.time.LocalDateTime; - -/** - * 내 대여 이력 목록 한 건. 이력에 물품 이름·이미지를 붙인 읽기 모델이다. - * 물품이 없으면(참조 무결성은 DB가 아닌 애플리케이션이 관리한다) 이름·이미지는 null이다. - */ -public record RentalHistorySummary( - Long historyId, - String itemName, - String itemImageKey, - LocalDateTime rentAt, - LocalDateTime returnedAt, - RentalStatus status -) { - - /** - * @param item 이력의 물품. 물품이 사라졌으면 {@code null} - */ - public static RentalHistorySummary of(RentalHistory history, Item item) { - return new RentalHistorySummary( - history.getId(), - item == null ? null : item.getName(), - item == null ? null : item.getImageKey(), - history.getRentAt(), - history.getReturnedAt(), - history.getRentalStatus() - ); - } -} diff --git a/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/rental/domain/RentalRecord.java b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/rental/domain/RentalRecord.java new file mode 100644 index 00000000..530640c4 --- /dev/null +++ b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/rental/domain/RentalRecord.java @@ -0,0 +1,37 @@ +package kr.ac.kookmin.stream.welfare.domain.rental.domain; + +import java.time.LocalDateTime; + +/** + * 대여 이력에 그 물품을 붙인 조회용 조합 객체. 필드를 복사하지 않고 도메인 객체를 그대로 담아, + * 화면이 어떤 필드를 쓰는지는 응답(api)이 고른다. + *

+ * 물품 참조는 DB가 아닌 애플리케이션이 관리하므로 {@code item}은 사라진 물품이면 null이다. + */ +public record RentalRecord(RentalHistory history, Item item) { + + /** + * 지금 반납해야 하는 대여인지. 대여 중이고, 대여 시각이 있고, 반납 정책이 있는 물품이어야 기한을 계산할 수 있다. + */ + public boolean isReturnRequired() { + return history.getRentalStatus() == RentalStatus.RENTAL + && history.getRentAt() != null + && item != null + && item.getReturnPolicy() != null; + } + + /** + * 반납 기한 시각. {@link #isReturnRequired()}가 true일 때만 계산할 수 있다. + */ + public LocalDateTime dueAt() { + return item.getReturnPolicy().dueAt(history.getRentAt()); + } + + public String itemName() { + return item == null ? null : item.getName(); + } + + public String itemImageKey() { + return item == null ? null : item.getImageKey(); + } +} diff --git a/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/rental/domain/ReturnRequiredRental.java b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/rental/domain/ReturnRequiredRental.java deleted file mode 100644 index 7279989d..00000000 --- a/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/rental/domain/ReturnRequiredRental.java +++ /dev/null @@ -1,27 +0,0 @@ -package kr.ac.kookmin.stream.welfare.domain.rental.domain; - -import java.time.LocalDateTime; - -/** - * 반납해야 하는 대여 한 건. 대여 시각과 물품의 반납 정책으로 계산한 반납 기한을 함께 담는다. - */ -public record ReturnRequiredRental( - Long historyId, - String itemName, - String itemImageKey, - LocalDateTime dueAt -) { - - /** - * @param history 대여 시각(rentAt)이 있는 이력 - * @param item 반납 정책이 있는 물품 - */ - public static ReturnRequiredRental of(RentalHistory history, Item item) { - return new ReturnRequiredRental( - history.getId(), - item.getName(), - item.getImageKey(), - item.getReturnPolicy().dueAt(history.getRentAt()) - ); - } -} 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..fcbd7aad 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,9 +1,8 @@ package kr.ac.kookmin.stream.welfare.domain.rental.service; import java.util.List; -import kr.ac.kookmin.stream.welfare.domain.rental.domain.RentalHistorySummary; +import kr.ac.kookmin.stream.welfare.domain.rental.domain.RentalRecord; import kr.ac.kookmin.stream.welfare.domain.rental.domain.RentalStatus; -import kr.ac.kookmin.stream.welfare.domain.rental.domain.ReturnRequiredRental; public interface RentalHistoryService { @@ -12,11 +11,11 @@ public interface RentalHistoryService { * * @param status 대여 상태. null이면 전체 */ - List getHistories(Long memberId, RentalStatus status); + List getHistories(Long memberId, RentalStatus status); /** - * 회원이 지금 반납해야 하는 대여(대여 중 상태)와 반납 기한을 조회한다. + * 회원이 지금 반납해야 하는 대여를 조회한다. 반납 기한은 {@link RentalRecord#dueAt()}으로 얻는다. * 반납 정책이 없는 물품의 대여는 기한을 계산할 수 없어 제외한다. */ - List getReturnRequiredRentals(Long memberId); + List getReturnRequiredRentals(Long memberId); } 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 410fa2d1..80258a56 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 @@ -6,9 +6,8 @@ import java.util.stream.Collectors; 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.RentalRecord; import kr.ac.kookmin.stream.welfare.domain.rental.domain.RentalStatus; -import kr.ac.kookmin.stream.welfare.domain.rental.domain.ReturnRequiredRental; 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.RentalHistoryService; @@ -24,28 +23,26 @@ class RentalHistoryServiceImpl implements RentalHistoryService { // 이력과 물품을 두 번에 나눠 읽어 서비스가 짝짓는다(coding-style.md 2-6). 지금은 items에 쓰기 경로가 // 없어 두 조회 사이에 물품이 바뀔 수 없으므로 트랜잭션이 필요 없다. 물품 이름 수정·신규 등록 정도만 - // 생기는 한 이 필드는 필터·계산에 안 쓰여 트랜잭션 없이도 안전하다. 반납 정책·타입처럼 hasDueAt·dueAt + // 생기는 한 이 필드는 필터·계산에 안 쓰여 트랜잭션 없이도 안전하다. 반납 정책·타입처럼 isReturnRequired·dueAt // 계산에 쓰이는 필드를 수정하는 기능이 생기면 그때 @Transactional(readOnly = true)를 다시 붙인다. @Override - public List getHistories(Long memberId, RentalStatus status) { - List histories = rentalHistoryRepository.findAllByMemberId(memberId, status); - Map items = findItemsOf(histories); - - return histories.stream() - .map(history -> RentalHistorySummary.of(history, items.get(history.getItemId()))) - .toList(); + public List getHistories(Long memberId, RentalStatus status) { + return toRecords(rentalHistoryRepository.findAllByMemberId(memberId, status)); } - // getHistories와 같은 이유로 트랜잭션이 필요 없다. hasDueAt이 보는 returnPolicy는 지금 계획된 + // getHistories와 같은 이유로 트랜잭션이 필요 없다. isReturnRequired가 보는 returnPolicy는 지금 계획된 // 쓰기(이름 수정·신규 등록)로는 바뀌지 않으므로, 두 조회 사이에 반납 필요 여부가 달라지지 않는다. @Override - public List getReturnRequiredRentals(Long memberId) { - List histories = rentalHistoryRepository.findAllByMemberId(memberId, RentalStatus.RENTAL); - Map items = findItemsOf(histories); + public List getReturnRequiredRentals(Long memberId) { + return toRecords(rentalHistoryRepository.findAllByMemberId(memberId, RentalStatus.RENTAL)).stream() + .filter(RentalRecord::isReturnRequired) + .toList(); + } + private List toRecords(List histories) { + Map items = findItemsOf(histories); return histories.stream() - .filter(history -> hasDueAt(history, items.get(history.getItemId()))) - .map(history -> ReturnRequiredRental.of(history, items.get(history.getItemId()))) + .map(history -> new RentalRecord(history, items.get(history.getItemId()))) .toList(); } @@ -57,9 +54,4 @@ private Map findItemsOf(List histories) { return itemRepository.findAllByIds(itemIds).stream() .collect(Collectors.toMap(Item::getId, Function.identity())); } - - // 대여 시각과 물품의 반납 정책이 모두 있어야 반납 기한을 계산할 수 있다 - private boolean hasDueAt(RentalHistory history, Item item) { - return history.getRentAt() != null && item != null && item.getReturnPolicy() != null; - } } From d57dc7d647613ca99e3a23f349b6730339293fa5 Mon Sep 17 00:00:00 2001 From: jjunh33 Date: Mon, 28 Sep 2026 16:34:25 +0900 Subject: [PATCH 7/8] =?UTF-8?q?refactor:=20=EB=AC=BC=ED=92=88=20=EB=AA=A9?= =?UTF-8?q?=EB=A1=9D=EC=9D=98=20=EC=B2=AB=20=ED=8E=98=EC=9D=B4=EC=A7=80?= =?UTF-8?q?=C2=B7=EB=8B=A4=EC=9D=8C=20=ED=8E=98=EC=9D=B4=EC=A7=80=20?= =?UTF-8?q?=EC=BF=BC=EB=A6=AC=EB=A5=BC=20=ED=95=98=EB=82=98=EB=A1=9C=20?= =?UTF-8?q?=ED=95=A9=EC=B9=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../stream/db/welfare/ItemJpaRepository.java | 17 +++-------------- .../stream/db/welfare/ItemRepositoryImpl.java | 9 ++++++--- 2 files changed, 9 insertions(+), 17 deletions(-) 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..0f1936b2 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 @@ -11,26 +11,15 @@ public interface ItemJpaRepository extends JpaRepository { // keywordPattern은 LIKE 패턴이다. 검색어의 %·_를 이스케이프한 뒤 '!'를 이스케이프 문자로 쓴다 // ('\\'는 MySQL 문자열 리터럴에서 다시 이스케이프되어 쓸 수 없다) + // 커서가 없으면 첫 페이지, 있으면 그 다음부터 조회한다 @Query(""" SELECT i FROM ItemJpaEntity i WHERE (:category IS NULL OR i.category = :category) AND (:keywordPattern IS NULL OR i.name LIKE :keywordPattern ESCAPE '!') + AND (:cursorName IS NULL OR i.name > :cursorName OR (i.name = :cursorName AND i.id > :cursorId)) ORDER BY i.name ASC, i.id ASC """) - List findFirstSlice( - @Param("category") ItemCategory category, - @Param("keywordPattern") String keywordPattern, - Pageable pageable - ); - - @Query(""" - SELECT i FROM ItemJpaEntity i - WHERE (:category IS NULL OR i.category = :category) - AND (:keywordPattern IS NULL OR i.name LIKE :keywordPattern ESCAPE '!') - AND (i.name > :cursorName OR (i.name = :cursorName AND i.id > :cursorId)) - ORDER BY i.name ASC, i.id ASC - """) - List findNextSlice( + List findSlice( @Param("category") ItemCategory category, @Param("keywordPattern") String keywordPattern, @Param("cursorName") String cursorName, 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..ed253a80 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 @@ -23,9 +23,12 @@ public class ItemRepositoryImpl implements ItemRepository { public CursorSliceResult findSlice(ItemCategory category, String keyword, ItemCursor cursor, int size) { Pageable pageable = Pageable.ofSize(size + 1); String keywordPattern = keyword == null ? null : toContainsPattern(keyword); - List entities = cursor == null - ? itemJpaRepository.findFirstSlice(category, keywordPattern, pageable) - : itemJpaRepository.findNextSlice(category, keywordPattern, cursor.name(), cursor.id(), pageable); + List entities = itemJpaRepository.findSlice( + category, keywordPattern, + cursor == null ? null : cursor.name(), + cursor == null ? null : cursor.id(), + pageable + ); return CursorSliceResult.ofSlice( entities, size, ItemJpaEntity::toDomain, item -> ItemCursor.of(item).format() From 6cdf462ea73d23cb6688172ab160f86b95ed9015 Mon Sep 17 00:00:00 2001 From: jjunh33 Date: Mon, 28 Sep 2026 19:42:46 +0900 Subject: [PATCH 8/8] =?UTF-8?q?refactor:=20=EB=AC=BC=ED=92=88=20=EA=B2=80?= =?UTF-8?q?=EC=83=89=EC=9D=84=20LIKE=20=ED=8C=A8=ED=84=B4=20=EC=A1=B0?= =?UTF-8?q?=EB=A6=BD=20=EB=8C=80=EC=8B=A0=20LOCATE=20=EC=BF=BC=EB=A6=AC?= =?UTF-8?q?=EB=A1=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../stream/db/welfare/ItemJpaRepository.java | 9 ++++----- .../stream/db/welfare/ItemRepositoryImpl.java | 14 +------------- 2 files changed, 5 insertions(+), 18 deletions(-) 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 0f1936b2..2bb2e7fc 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 @@ -9,19 +9,18 @@ public interface ItemJpaRepository extends JpaRepository { - // keywordPattern은 LIKE 패턴이다. 검색어의 %·_를 이스케이프한 뒤 '!'를 이스케이프 문자로 쓴다 - // ('\\'는 MySQL 문자열 리터럴에서 다시 이스케이프되어 쓸 수 없다) - // 커서가 없으면 첫 페이지, 있으면 그 다음부터 조회한다 + // 커서가 없으면 첫 페이지, 있으면 그 다음부터 조회한다. keyword는 이름 부분 일치 검색이다. + // LIKE 대신 LOCATE를 써서 %·_ 같은 문자도 와일드카드가 아닌 글자 그대로 검색된다 @Query(""" SELECT i FROM ItemJpaEntity i WHERE (:category IS NULL OR i.category = :category) - AND (:keywordPattern IS NULL OR i.name LIKE :keywordPattern ESCAPE '!') + AND (:keyword IS NULL OR LOCATE(:keyword, i.name) > 0) AND (:cursorName IS NULL OR i.name > :cursorName OR (i.name = :cursorName AND i.id > :cursorId)) ORDER BY i.name ASC, i.id ASC """) List findSlice( @Param("category") ItemCategory category, - @Param("keywordPattern") String keywordPattern, + @Param("keyword") String keyword, @Param("cursorName") String cursorName, @Param("cursorId") Long cursorId, Pageable pageable 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 ed253a80..d2e42c97 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 @@ -15,16 +15,13 @@ @RequiredArgsConstructor public class ItemRepositoryImpl implements ItemRepository { - private static final char LIKE_ESCAPE = '!'; - private final ItemJpaRepository itemJpaRepository; @Override public CursorSliceResult findSlice(ItemCategory category, String keyword, ItemCursor cursor, int size) { Pageable pageable = Pageable.ofSize(size + 1); - String keywordPattern = keyword == null ? null : toContainsPattern(keyword); List entities = itemJpaRepository.findSlice( - category, keywordPattern, + category, keyword, cursor == null ? null : cursor.name(), cursor == null ? null : cursor.id(), pageable @@ -41,13 +38,4 @@ public List findAllByIds(Collection ids) { .map(ItemJpaEntity::toDomain) .toList(); } - - // 검색어를 "포함" 조건의 LIKE 패턴으로 바꾼다. 검색어 안의 와일드카드가 패턴으로 해석되지 않게 이스케이프한다 - private String toContainsPattern(String keyword) { - String escaped = keyword - .replace(String.valueOf(LIKE_ESCAPE), "" + LIKE_ESCAPE + LIKE_ESCAPE) - .replace("%", LIKE_ESCAPE + "%") - .replace("_", LIKE_ESCAPE + "_"); - return "%" + escaped + "%"; - } }