From 9f570b45934f7ad3d8bacbb5e0fb8d568e500586 Mon Sep 17 00:00:00 2001 From: sangrae Date: Wed, 23 Sep 2026 19:54:41 +0900 Subject: [PATCH 01/14] =?UTF-8?q?feat:=20=EC=82=AC=EB=AC=BC=ED=95=A8=20?= =?UTF-8?q?=EA=B5=AC=EC=97=AD=20=ED=85=8C=EC=9D=B4=EB=B8=94=EA=B3=BC=20?= =?UTF-8?q?=EC=9A=B4=EC=98=81=20=ED=9A=8C=EC=B0=A8=20=EA=B2=8C=EC=8B=9C=20?= =?UTF-8?q?=EC=97=AC=EB=B6=80,=20=EC=8B=A0=EC=B2=AD=20=EC=9C=A0=EB=8B=88?= =?UTF-8?q?=ED=81=AC=20=EC=A0=9C=EC=95=BD=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../event/domain/locker/domain/Locker.java | 14 ++++--- .../domain/locker/domain/LockerPeriod.java | 7 +++- .../domain/locker/domain/LockerSection.java | 22 +++++++++++ .../db/event/LockerApplicationJpaEntity.java | 7 +++- .../stream/db/event/LockerJpaEntity.java | 25 ++++++++---- .../db/event/LockerPeriodJpaEntity.java | 6 ++- .../db/event/LockerSectionJpaEntity.java | 39 +++++++++++++++++++ ...__add_locker_sections_and_publish_flag.sql | 38 ++++++++++++++++++ 8 files changed, 142 insertions(+), 16 deletions(-) create mode 100644 core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/domain/LockerSection.java create mode 100644 infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/LockerSectionJpaEntity.java create mode 100644 infrastructure/db/src/main/resources/db/migration/V9__add_locker_sections_and_publish_flag.sql diff --git a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/domain/Locker.java b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/domain/Locker.java index 83d9bd56..8cdf3140 100644 --- a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/domain/Locker.java +++ b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/domain/Locker.java @@ -11,20 +11,24 @@ public class Locker { private Long id; - private String lockerNumber; - private String section; + private Long sectionId; + /** 화면에 표시할 사물함 이름. "A-37" 형태로 관리자가 직접 입력하며 번호에서 유도하지 않는다. */ + private String lockerLabel; + /** 블록 안에서 이어지는 사물함 순번. A-1 구역이 38번까지면 A-2 구역은 39번부터 시작한다. */ + private int lockerNumber; private int rowNo; private int columnNo; private LockerStatus status; public static Locker of( Long id, - String lockerNumber, - String section, + Long sectionId, + String lockerLabel, + int lockerNumber, int rowNo, int columnNo, LockerStatus status ) { - return new Locker(id, lockerNumber, section, rowNo, columnNo, status); + return new Locker(id, sectionId, lockerLabel, lockerNumber, rowNo, columnNo, status); } } diff --git a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/domain/LockerPeriod.java b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/domain/LockerPeriod.java index 04e16cb1..4734aca4 100644 --- a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/domain/LockerPeriod.java +++ b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/domain/LockerPeriod.java @@ -18,6 +18,8 @@ public class LockerPeriod { private LocalDateTime applyEndAt; private LocalDate usageStartAt; private LocalDate usageEndAt; + /** 운영진이 학생에게 공개했는지. 미게시 회차는 학생에게 없는 것으로 보여야 한다. */ + private boolean published; public static LockerPeriod of( Long id, @@ -25,8 +27,9 @@ public static LockerPeriod of( LocalDateTime applyStartAt, LocalDateTime applyEndAt, LocalDate usageStartAt, - LocalDate usageEndAt + LocalDate usageEndAt, + boolean published ) { - return new LockerPeriod(id, name, applyStartAt, applyEndAt, usageStartAt, usageEndAt); + return new LockerPeriod(id, name, applyStartAt, applyEndAt, usageStartAt, usageEndAt, published); } } diff --git a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/domain/LockerSection.java b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/domain/LockerSection.java new file mode 100644 index 00000000..3e5d22c1 --- /dev/null +++ b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/domain/LockerSection.java @@ -0,0 +1,22 @@ +package kr.ac.kookmin.stream.event.domain.locker.domain; + +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.EqualsAndHashCode; +import lombok.Getter; + +/** + * 사물함 구역. 사물함을 묶는 단위이자 구역 목록·구역 상세 조회의 기준이다. + */ +@Getter +@EqualsAndHashCode +@AllArgsConstructor(access = AccessLevel.PRIVATE) +public class LockerSection { + + private Long id; + private String label; + + public static LockerSection of(Long id, String label) { + return new LockerSection(id, label); + } +} diff --git a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/LockerApplicationJpaEntity.java b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/LockerApplicationJpaEntity.java index 43da1796..fcce791d 100644 --- a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/LockerApplicationJpaEntity.java +++ b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/LockerApplicationJpaEntity.java @@ -17,7 +17,12 @@ name = "locker_applications", indexes = { @Index(name = "idx_locker_applications_locker_period_id_locker_id", columnList = "locker_period_id, locker_id"), - @Index(name = "idx_locker_applications_member_id", columnList = "member_id") + @Index(name = "idx_locker_applications_member_id", columnList = "member_id"), + @Index( + name = "uk_locker_applications_locker_period_id_member_id", + columnList = "locker_period_id, member_id", + unique = true + ) } ) @NoArgsConstructor(access = AccessLevel.PROTECTED) diff --git a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/LockerJpaEntity.java b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/LockerJpaEntity.java index 31d09d11..9c38a8af 100644 --- a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/LockerJpaEntity.java +++ b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/LockerJpaEntity.java @@ -7,6 +7,7 @@ import jakarta.persistence.GeneratedValue; import jakarta.persistence.GenerationType; import jakarta.persistence.Id; +import jakarta.persistence.Index; import jakarta.persistence.Table; import kr.ac.kookmin.stream.db.common.BaseSoftDeleteEntity; import kr.ac.kookmin.stream.event.domain.locker.domain.Locker; @@ -15,7 +16,13 @@ import lombok.NoArgsConstructor; @Entity -@Table(name = "lockers") +@Table( + name = "lockers", + indexes = @Index( + name = "idx_lockers_section_id_is_deleted_row_no_column_no", + columnList = "section_id, is_deleted, row_no, column_no" + ) +) @NoArgsConstructor(access = AccessLevel.PROTECTED) public class LockerJpaEntity extends BaseSoftDeleteEntity { @@ -24,11 +31,14 @@ public class LockerJpaEntity extends BaseSoftDeleteEntity { @Column(name = "locker_id") private Long id; - @Column(name = "locker_number", nullable = false, length = 20) - private String lockerNumber; + @Column(name = "section_id", nullable = false) + private Long sectionId; - @Column(nullable = false, length = 20) - private String section; + @Column(name = "locker_label", nullable = false, length = 20) + private String lockerLabel; + + @Column(name = "locker_number", nullable = false) + private int lockerNumber; @Column(name = "row_no", nullable = false) private int rowNo; @@ -42,8 +52,9 @@ public class LockerJpaEntity extends BaseSoftDeleteEntity { private LockerJpaEntity(Locker locker) { this.id = locker.getId(); + this.sectionId = locker.getSectionId(); + this.lockerLabel = locker.getLockerLabel(); this.lockerNumber = locker.getLockerNumber(); - this.section = locker.getSection(); this.rowNo = locker.getRowNo(); this.columnNo = locker.getColumnNo(); this.status = locker.getStatus(); @@ -54,6 +65,6 @@ public static LockerJpaEntity from(Locker locker) { } public Locker toDomain() { - return Locker.of(id, lockerNumber, section, rowNo, columnNo, status); + return Locker.of(id, sectionId, lockerLabel, lockerNumber, rowNo, columnNo, status); } } diff --git a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/LockerPeriodJpaEntity.java b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/LockerPeriodJpaEntity.java index 98262c4c..afcac785 100644 --- a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/LockerPeriodJpaEntity.java +++ b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/LockerPeriodJpaEntity.java @@ -38,6 +38,9 @@ public class LockerPeriodJpaEntity extends BaseTimeEntity { @Column(name = "usage_end_at", nullable = false) private LocalDate usageEndAt; + @Column(name = "is_published", nullable = false) + private boolean isPublished; + private LockerPeriodJpaEntity(LockerPeriod period) { this.id = period.getId(); this.name = period.getName(); @@ -45,6 +48,7 @@ private LockerPeriodJpaEntity(LockerPeriod period) { this.applyEndAt = period.getApplyEndAt(); this.usageStartAt = period.getUsageStartAt(); this.usageEndAt = period.getUsageEndAt(); + this.isPublished = period.isPublished(); } public static LockerPeriodJpaEntity from(LockerPeriod period) { @@ -52,6 +56,6 @@ public static LockerPeriodJpaEntity from(LockerPeriod period) { } public LockerPeriod toDomain() { - return LockerPeriod.of(id, name, applyStartAt, applyEndAt, usageStartAt, usageEndAt); + return LockerPeriod.of(id, name, applyStartAt, applyEndAt, usageStartAt, usageEndAt, isPublished); } } diff --git a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/LockerSectionJpaEntity.java b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/LockerSectionJpaEntity.java new file mode 100644 index 00000000..0b34cfa4 --- /dev/null +++ b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/LockerSectionJpaEntity.java @@ -0,0 +1,39 @@ +package kr.ac.kookmin.stream.db.event; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import kr.ac.kookmin.stream.db.common.BaseTimeEntity; +import kr.ac.kookmin.stream.event.domain.locker.domain.LockerSection; +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +@Entity +@Table(name = "locker_sections") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class LockerSectionJpaEntity extends BaseTimeEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "locker_section_id") + private Long id; + + @Column(nullable = false, length = 20) + private String label; + + private LockerSectionJpaEntity(LockerSection section) { + this.id = section.getId(); + this.label = section.getLabel(); + } + + public static LockerSectionJpaEntity from(LockerSection section) { + return new LockerSectionJpaEntity(section); + } + + public LockerSection toDomain() { + return LockerSection.of(id, label); + } +} diff --git a/infrastructure/db/src/main/resources/db/migration/V9__add_locker_sections_and_publish_flag.sql b/infrastructure/db/src/main/resources/db/migration/V9__add_locker_sections_and_publish_flag.sql new file mode 100644 index 00000000..be03433f --- /dev/null +++ b/infrastructure/db/src/main/resources/db/migration/V9__add_locker_sections_and_publish_flag.sql @@ -0,0 +1,38 @@ +-- 사물함 구역을 lockers.section 문자열에서 별도 테이블로 승격하고, 운영 회차에 게시 여부를 추가한다. +-- +-- ⚠️ lockers에 운영 데이터가 없다는 전제다. 관리자 사물함 등록 API가 아직 없어 행이 쌓일 경로가 없고, +-- locker_label은 관리자가 직접 입력하는 값이라 기존 행에서 유도할 수 없다. +-- 적용 전 SELECT COUNT(*) FROM lockers 로 확인한다. + +CREATE TABLE locker_sections ( + locker_section_id BIGINT AUTO_INCREMENT PRIMARY KEY, + label VARCHAR(20) NOT NULL, -- 화면에 표시할 구역 라벨. "A-1", "A-2", "C" + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP +); +CREATE UNIQUE INDEX uk_locker_sections_label ON locker_sections (label); + +-- locker_number를 INT로 바꾼다. 사물함 번호는 블록 안에서 이어지는 순번이라(A-1이 38번까지면 A-2는 39번부터) +-- 문자열로 두면 정렬이 사전순("1","10","2")이 되고 번호 범위를 다루기 어렵다. +-- locker_label("A-37")은 번호에서 유도하지 않고 관리자가 직접 입력한다. +ALTER TABLE lockers + ADD COLUMN section_id BIGINT NOT NULL, + ADD COLUMN locker_label VARCHAR(20) NOT NULL, + MODIFY COLUMN locker_number INT NOT NULL, + DROP COLUMN section; + +-- 구역 상세 조회: WHERE section_id = ? AND is_deleted = 0 ORDER BY row_no, column_no +-- 필터 컬럼을 앞, 정렬 컬럼을 뒤에 둬 filesort 없이 배치도 순서대로 읽는다. +CREATE INDEX idx_lockers_section_id_is_deleted_row_no_column_no + ON lockers (section_id, is_deleted, row_no, column_no); + +-- 한 회차에 회원당 사물함 한 개만 신청할 수 있고 수정·취소가 없다. 신청 건수 제한을 DB 제약으로 못박는다. +-- 취소가 없어 상태 컬럼이 없으므로 행 존재 자체가 신청이고, soft delete용 generated column 패턴도 필요 없다. +-- 기존 idx_locker_applications_member_id는 유니크의 좌측 prefix가 아니라(회차가 앞) 회원 단독 조회에 여전히 쓰여 남긴다. +CREATE UNIQUE INDEX uk_locker_applications_locker_period_id_member_id + ON locker_applications (locker_period_id, member_id); + +-- 운영진이 준비 중인 회차가 학생에게 노출되지 않도록 게시 여부를 둔다. +-- events(V5)와 같은 방식이며, 관리자 게시 API가 생기기 전까지는 DB에서 직접 켜야 한다. +ALTER TABLE locker_periods + ADD COLUMN is_published TINYINT(1) NOT NULL DEFAULT 0; From 3cc975408ca3380c065d860f9b868a96c377a3e8 Mon Sep 17 00:00:00 2001 From: sangrae Date: Thu, 24 Sep 2026 01:21:54 +0900 Subject: [PATCH 02/14] =?UTF-8?q?feat:=20=EC=82=AC=EB=AC=BC=ED=95=A8=20?= =?UTF-8?q?=EA=B5=AC=EC=97=AD=20=EC=A1=B0=ED=9A=8C=20=EB=8F=84=EB=A9=94?= =?UTF-8?q?=EC=9D=B8=20=EB=AA=A8=EB=8D=B8=EA=B3=BC=20=ED=8F=AC=ED=8A=B8=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 --- .../event/domain/locker/domain/Locker.java | 11 ++++ .../locker/domain/LockerAvailability.java | 31 +++++++++++ .../domain/locker/domain/LockerErrorCode.java | 19 +++++++ .../locker/domain/LockerSectionSummary.java | 42 +++++++++++++++ .../domain/SectionAvailabilityStatus.java | 38 ++++++++++++++ .../locker/repository/LockerRepository.java | 51 +++++++++++++++++++ .../domain/locker/service/LockerService.java | 18 +++++++ 7 files changed, 210 insertions(+) create mode 100644 core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/domain/LockerAvailability.java create mode 100644 core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/domain/LockerErrorCode.java create mode 100644 core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/domain/LockerSectionSummary.java create mode 100644 core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/domain/SectionAvailabilityStatus.java create mode 100644 core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/repository/LockerRepository.java create mode 100644 core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/service/LockerService.java diff --git a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/domain/Locker.java b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/domain/Locker.java index 8cdf3140..552dc6f8 100644 --- a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/domain/Locker.java +++ b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/domain/Locker.java @@ -31,4 +31,15 @@ public static Locker of( ) { return new Locker(id, sectionId, lockerLabel, lockerNumber, rowNo, columnNo, status); } + + /** + * 해당 운영 회차에서 선택할 수 있는지. 사물함 자체 상태와 신청 여부를 함께 본다. + *

+ * 구역 목록의 선택 가능 수와 구역 상세의 선택 가능 여부가 같은 기준을 써야 하므로 도메인에 둔다. + * + * @param applied 해당 운영 회차에 이 사물함이 이미 신청되었는지 + */ + public boolean isSelectable(boolean applied) { + return status == LockerStatus.AVAILABLE && !applied; + } } diff --git a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/domain/LockerAvailability.java b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/domain/LockerAvailability.java new file mode 100644 index 00000000..4cdd2e06 --- /dev/null +++ b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/domain/LockerAvailability.java @@ -0,0 +1,31 @@ +package kr.ac.kookmin.stream.event.domain.locker.domain; + +/** + * 배치도에 그릴 사물함 한 건. 선택 가능 여부는 사물함 자체 상태와 해당 운영 회차의 신청 여부를 함께 본다. + */ +public record LockerAvailability( + Long lockerId, + String lockerLabel, + int lockerNumber, + int rowNo, + int columnNo, + boolean available, + boolean mine +) { + + /** + * @param applied 해당 운영 회차에 이 사물함이 이미 신청되었는지 + * @param mine 그 신청이 조회한 회원의 것인지 + */ + public static LockerAvailability of(Locker locker, boolean applied, boolean mine) { + return new LockerAvailability( + locker.getId(), + locker.getLockerLabel(), + locker.getLockerNumber(), + locker.getRowNo(), + locker.getColumnNo(), + locker.isSelectable(applied), + mine + ); + } +} diff --git a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/domain/LockerErrorCode.java b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/domain/LockerErrorCode.java new file mode 100644 index 00000000..c71c375d --- /dev/null +++ b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/domain/LockerErrorCode.java @@ -0,0 +1,19 @@ +package kr.ac.kookmin.stream.event.domain.locker.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 LockerErrorCode implements ErrorCode { + + LOCKER_PERIOD_NOT_FOUND(ErrorStatus.NOT_FOUND, "사물함 운영 회차를 찾을 수 없습니다."), + LOCKER_SECTION_NOT_FOUND(ErrorStatus.NOT_FOUND, "사물함 구역을 찾을 수 없습니다."); + + private final int status; + private final String message; +} diff --git a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/domain/LockerSectionSummary.java b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/domain/LockerSectionSummary.java new file mode 100644 index 00000000..7e01aa68 --- /dev/null +++ b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/domain/LockerSectionSummary.java @@ -0,0 +1,42 @@ +package kr.ac.kookmin.stream.event.domain.locker.domain; + +import java.util.List; +import java.util.Set; + +/** + * 사물함 구역 목록 한 건. 전체·선택 가능 수와 표시 상태는 저장값이 아니라 조회 시점에 센다. + */ +public record LockerSectionSummary( + Long sectionId, + String label, + int availableCount, + int totalCount, + SectionAvailabilityStatus availabilityStatus, + boolean hasMine +) { + + /** + * @param lockers 구역에 속한 사물함. 사물함이 없는 구역이면 빈 목록이다 + * @param appliedLockerIds 해당 운영 회차에 이미 신청된 사물함 식별자 + */ + public static LockerSectionSummary of( + LockerSection section, + List lockers, + Set appliedLockerIds, + boolean hasMine + ) { + int totalCount = lockers.size(); + int availableCount = (int) lockers.stream() + .filter(locker -> locker.isSelectable(appliedLockerIds.contains(locker.getId()))) + .count(); + + return new LockerSectionSummary( + section.getId(), + section.getLabel(), + availableCount, + totalCount, + SectionAvailabilityStatus.from(availableCount, totalCount), + hasMine + ); + } +} diff --git a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/domain/SectionAvailabilityStatus.java b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/domain/SectionAvailabilityStatus.java new file mode 100644 index 00000000..61651b68 --- /dev/null +++ b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/domain/SectionAvailabilityStatus.java @@ -0,0 +1,38 @@ +package kr.ac.kookmin.stream.event.domain.locker.domain; + +/** + * 구역에 남은 사물함 수로 정해지는 표시 상태. + *

+ * 판정에 구역의 속성이 쓰이지 않고 선택 가능 수와 전체 수만 필요해, 상태 타입이 자기 생성을 소유한다. + * 구역별로 임계값이 달라지면 그때 {@link LockerSection}이 임계값을 갖고 판정을 가져가는 편이 맞다. + */ +public enum SectionAvailabilityStatus { + + PLENTY, + NORMAL, + ALMOST_FULL, + FULL; + + private static final double PLENTY_RATE = 0.5; + private static final double NORMAL_RATE = 0.2; + + /** + * @param availableCount 선택 가능한 사물함 수 + * @param totalCount 구역의 전체 사물함 수(사용 중지된 사물함 포함) + */ + public static SectionAvailabilityStatus from(int availableCount, int totalCount) { + // 사물함이 하나도 없는 구역의 0 나누기도 여기서 함께 걸린다 + if (availableCount <= 0) { + return FULL; + } + + double availabilityRate = (double) availableCount / totalCount; + if (availabilityRate >= PLENTY_RATE) { + return PLENTY; + } + if (availabilityRate >= NORMAL_RATE) { + return NORMAL; + } + return ALMOST_FULL; + } +} diff --git a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/repository/LockerRepository.java b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/repository/LockerRepository.java new file mode 100644 index 00000000..9b06af6c --- /dev/null +++ b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/repository/LockerRepository.java @@ -0,0 +1,51 @@ +package kr.ac.kookmin.stream.event.domain.locker.repository; + +import java.util.List; +import java.util.Optional; +import java.util.Set; +import kr.ac.kookmin.stream.event.domain.locker.domain.Locker; +import kr.ac.kookmin.stream.event.domain.locker.domain.LockerSection; + +public interface LockerRepository { + + /** + * 게시된 운영 회차가 있는지. 아직 공개하지 않은 회차는 학생에게 없는 것으로 보여야 한다. + *

+ * 조회 API는 회차의 존재만 확인하면 되므로 회차 자체는 읽지 않는다. 신청 기간 판정처럼 회차 값이 + * 필요해지면 그때 조회 메서드를 추가한다. + */ + boolean existsPublishedPeriod(Long lockerPeriodId); + + boolean existsSection(Long sectionId); + + /** + * 전체 구역을 식별자 오름차순으로 조회한다. 화면이 구역 자리를 알고 채우므로 별도 표시 순서는 두지 않고, + * 같은 요청이 항상 같은 순서를 돌려주도록 식별자로만 정렬한다. + */ + List findAllSections(); + + /** + * 삭제되지 않은 사물함 전체를 조회한다. 구역별 집계는 서비스가 이 목록을 묶어 센다. + *

+ * 선택 가능 판정({@code Locker.isSelectable})을 SQL로 옮기지 않으려는 선택이다. 사물함은 물리적 수량이라 + * 규모가 유한하고 구역 목록은 어차피 전부 내려가므로 전부 읽어도 부담이 없다. + */ + List findAllLockers(); + + /** + * 구역에 속한 사물함을 배치도 순서(행 → 열)로 조회한다. + */ + List findLockersBySectionId(Long sectionId); + + /** + * 해당 운영 회차에 이미 신청된 사물함 식별자. + */ + Set findAppliedLockerIds(Long lockerPeriodId); + + /** + * 해당 운영 회차에서 회원이 신청한 사물함 식별자. 회차당 한 건만 신청할 수 있고 취소가 없어 최대 하나다. + *

+ * 어느 구역인지는 이미 읽어둔 사물함 목록에서 가려낼 수 있어 식별자만 돌려준다. + */ + Optional findAppliedLockerId(Long lockerPeriodId, Long memberId); +} diff --git a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/service/LockerService.java b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/service/LockerService.java new file mode 100644 index 00000000..7d0e524a --- /dev/null +++ b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/service/LockerService.java @@ -0,0 +1,18 @@ +package kr.ac.kookmin.stream.event.domain.locker.service; + +import java.util.List; +import kr.ac.kookmin.stream.event.domain.locker.domain.LockerAvailability; +import kr.ac.kookmin.stream.event.domain.locker.domain.LockerSectionSummary; + +public interface LockerService { + + /** + * 운영 회차의 구역별 전체/선택 가능 사물함 수와 표시 상태를 조회한다. + */ + List getSections(Long lockerPeriodId, Long memberId); + + /** + * 구역에 속한 사물함의 배치 정보와 선택 가능 여부를 조회한다. + */ + List getSectionLockers(Long lockerPeriodId, Long sectionId, Long memberId); +} From 7ac3e3537c5f1fb9c4887a28345838f35dc9d08a Mon Sep 17 00:00:00 2001 From: sangrae Date: Thu, 24 Sep 2026 01:21:54 +0900 Subject: [PATCH 03/14] =?UTF-8?q?feat:=20=EC=82=AC=EB=AC=BC=ED=95=A8=20?= =?UTF-8?q?=EA=B5=AC=EC=97=AD=20=EC=A1=B0=ED=9A=8C=20=EC=84=9C=EB=B9=84?= =?UTF-8?q?=EC=8A=A4=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/impl/LockerServiceImpl.java | 80 ++++++ .../domain/SectionAvailabilityStatusTest.java | 49 ++++ .../service/impl/LockerServiceImplTest.java | 260 ++++++++++++++++++ 3 files changed, 389 insertions(+) create mode 100644 core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/service/impl/LockerServiceImpl.java create mode 100644 core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/locker/domain/SectionAvailabilityStatusTest.java create mode 100644 core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/locker/service/impl/LockerServiceImplTest.java diff --git a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/service/impl/LockerServiceImpl.java b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/service/impl/LockerServiceImpl.java new file mode 100644 index 00000000..f9994ea7 --- /dev/null +++ b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/service/impl/LockerServiceImpl.java @@ -0,0 +1,80 @@ +package kr.ac.kookmin.stream.event.domain.locker.service.impl; + +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; +import kr.ac.kookmin.stream.common.BusinessException; +import kr.ac.kookmin.stream.event.domain.locker.domain.Locker; +import kr.ac.kookmin.stream.event.domain.locker.domain.LockerAvailability; +import kr.ac.kookmin.stream.event.domain.locker.domain.LockerErrorCode; +import kr.ac.kookmin.stream.event.domain.locker.domain.LockerSectionSummary; +import kr.ac.kookmin.stream.event.domain.locker.repository.LockerRepository; +import kr.ac.kookmin.stream.event.domain.locker.service.LockerService; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +class LockerServiceImpl implements LockerService { + + private final LockerRepository lockerRepository; + + @Override + @Transactional(readOnly = true) + public List getSections(Long lockerPeriodId, Long memberId) { + requirePublishedPeriod(lockerPeriodId); + + Set appliedLockerIds = lockerRepository.findAppliedLockerIds(lockerPeriodId); + Map> lockersBySection = lockerRepository.findAllLockers().stream() + .collect(Collectors.groupingBy(Locker::getSectionId)); + + Long myLockerId = lockerRepository.findAppliedLockerId(lockerPeriodId, memberId).orElse(null); + + return lockerRepository.findAllSections().stream() + .map(section -> { + // 사물함이 한 건도 없는 구역은 묶음에 키가 없다. 빈 목록으로 채워 목록에서 빠지지 않게 한다 + List sectionLockers = lockersBySection.getOrDefault(section.getId(), List.of()); + return LockerSectionSummary.of( + section, sectionLockers, appliedLockerIds, containsMine(sectionLockers, myLockerId)); + }) + .toList(); + } + + @Override + @Transactional(readOnly = true) + public List getSectionLockers(Long lockerPeriodId, Long sectionId, Long memberId) { + requirePublishedPeriod(lockerPeriodId); + requireSection(sectionId); + + Set appliedLockerIds = lockerRepository.findAppliedLockerIds(lockerPeriodId); + Long myLockerId = lockerRepository.findAppliedLockerId(lockerPeriodId, memberId).orElse(null); + + return lockerRepository.findLockersBySectionId(sectionId).stream() + .map(locker -> LockerAvailability.of( + locker, + appliedLockerIds.contains(locker.getId()), + locker.getId().equals(myLockerId) + )) + .toList(); + } + + /** 내 사물함이 이 구역에 있는지. 회차당 신청이 최대 한 건이라 참이 되는 구역도 하나뿐이다. */ + private boolean containsMine(List lockers, Long myLockerId) { + return myLockerId != null && lockers.stream().anyMatch(locker -> myLockerId.equals(locker.getId())); + } + + /** 아직 게시하지 않은 회차는 학생에게 없는 것으로 보여야 하므로 두 조회의 입구에서 같은 기준으로 거른다. */ + private void requirePublishedPeriod(Long lockerPeriodId) { + if (!lockerRepository.existsPublishedPeriod(lockerPeriodId)) { + throw new BusinessException(LockerErrorCode.LOCKER_PERIOD_NOT_FOUND); + } + } + + private void requireSection(Long sectionId) { + if (!lockerRepository.existsSection(sectionId)) { + throw new BusinessException(LockerErrorCode.LOCKER_SECTION_NOT_FOUND); + } + } +} diff --git a/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/locker/domain/SectionAvailabilityStatusTest.java b/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/locker/domain/SectionAvailabilityStatusTest.java new file mode 100644 index 00000000..4b2c273b --- /dev/null +++ b/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/locker/domain/SectionAvailabilityStatusTest.java @@ -0,0 +1,49 @@ +package kr.ac.kookmin.stream.event.domain.locker.domain; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; + +class SectionAvailabilityStatusTest { + + @ParameterizedTest + @DisplayName("명세의 예시가 모두 같은 상태로 판정된다") + @CsvSource({ + "24, 34, PLENTY", // 70.6% + "6, 6, PLENTY", // 100% + "24, 60, NORMAL", // 40% + "2, 40, ALMOST_FULL", // 5% + "0, 100, FULL" // 0% + }) + void specExamples(int available, int total, SectionAvailabilityStatus expected) { + assertEquals(expected, SectionAvailabilityStatus.from(available, total)); + } + + @ParameterizedTest + @DisplayName("잔여율 경계값은 더 여유로운 쪽으로 판정된다") + @CsvSource({ + "50, 100, PLENTY", // 정확히 50% + "49, 100, NORMAL", // 50% 바로 아래 + "20, 100, NORMAL", // 정확히 20% + "19, 100, ALMOST_FULL" // 20% 바로 아래 + }) + void boundaries(int available, int total, SectionAvailabilityStatus expected) { + assertEquals(expected, SectionAvailabilityStatus.from(available, total)); + } + + @Test + @DisplayName("선택 가능 수가 0이면 전체 수와 무관하게 마감이다") + void zeroAvailableIsFull() { + assertEquals(SectionAvailabilityStatus.FULL, SectionAvailabilityStatus.from(0, 100)); + assertEquals(SectionAvailabilityStatus.FULL, SectionAvailabilityStatus.from(0, 1)); + } + + @Test + @DisplayName("사물함이 없는 구역은 0으로 나누지 않고 마감으로 본다") + void emptySection() { + assertEquals(SectionAvailabilityStatus.FULL, SectionAvailabilityStatus.from(0, 0)); + } +} diff --git a/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/locker/service/impl/LockerServiceImplTest.java b/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/locker/service/impl/LockerServiceImplTest.java new file mode 100644 index 00000000..1b6a1f30 --- /dev/null +++ b/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/locker/service/impl/LockerServiceImplTest.java @@ -0,0 +1,260 @@ +package kr.ac.kookmin.stream.event.domain.locker.service.impl; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.List; +import java.util.Optional; +import java.util.Set; +import kr.ac.kookmin.stream.common.BusinessException; +import kr.ac.kookmin.stream.event.domain.locker.domain.Locker; +import kr.ac.kookmin.stream.event.domain.locker.domain.LockerAvailability; +import kr.ac.kookmin.stream.event.domain.locker.domain.LockerErrorCode; +import kr.ac.kookmin.stream.event.domain.locker.domain.LockerSection; +import kr.ac.kookmin.stream.event.domain.locker.domain.LockerSectionSummary; +import kr.ac.kookmin.stream.event.domain.locker.domain.LockerStatus; +import kr.ac.kookmin.stream.event.domain.locker.domain.SectionAvailabilityStatus; +import kr.ac.kookmin.stream.event.domain.locker.repository.LockerRepository; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +/** + * 구역·사물함·신청을 각각 조회해 서비스가 구역별로 묶어 센다. 그 집계와 조합이 어긋나지 않는지 확인한다. + */ +class LockerServiceImplTest { + + private static final Long PERIOD_ID = 1L; + private static final Long MEMBER_ID = 100L; + + private static LockerSection section(Long id, String label) { + return LockerSection.of(id, label); + } + + private static Locker locker(Long id, Long sectionId, LockerStatus status) { + return Locker.of(id, sectionId, "A-" + id, id.intValue(), 1, id.intValue(), status); + } + + private static Locker usable(Long id, Long sectionId) { + return locker(id, sectionId, LockerStatus.AVAILABLE); + } + + @Nested + @DisplayName("구역 목록") + class GetSections { + + @Test + @DisplayName("구역마다 자기 사물함만 세고, 사용 중지·신청된 것은 선택 가능 수에서 빠진다") + void countsPerSection() { + FakeLockerRepository repository = new FakeLockerRepository() + .withSections(section(1L, "A-1"), section(2L, "A-2")) + .withAllLockers( + usable(11L, 1L), + locker(12L, 1L, LockerStatus.DISABLED), + usable(13L, 1L), + usable(21L, 2L)) + .withAppliedLockerIds(13L); + + List sections = + new LockerServiceImpl(repository).getSections(PERIOD_ID, MEMBER_ID); + + assertEquals(List.of(1L, 2L), sections.stream().map(LockerSectionSummary::sectionId).toList()); + assertEquals(3, sections.get(0).totalCount()); + assertEquals(1, sections.get(0).availableCount()); // 12는 사용 중지, 13은 신청됨 + assertEquals(1, sections.get(1).totalCount()); + assertEquals(1, sections.get(1).availableCount()); + } + + @Test + @DisplayName("사물함이 한 건도 없는 구역도 0으로 채워 목록에서 빠지지 않는다") + void keepsSectionsWithoutLockers() { + // 구역별로 묶으면 사물함이 없는 2번 구역은 키 자체가 생기지 않는다 + FakeLockerRepository repository = new FakeLockerRepository() + .withSections(section(1L, "A-1"), section(2L, "A-2")) + .withAllLockers(usable(11L, 1L)); + + List sections = + new LockerServiceImpl(repository).getSections(PERIOD_ID, MEMBER_ID); + + assertEquals(2, sections.size()); + assertEquals(0, sections.get(1).totalCount()); + assertEquals(SectionAvailabilityStatus.FULL, sections.get(1).availabilityStatus()); + } + + @Test + @DisplayName("내 사물함이 있는 구역에만 hasMine이 붙는다") + void marksOnlyMySection() { + Locker mine = usable(21L, 2L); + FakeLockerRepository repository = new FakeLockerRepository() + .withSections(section(1L, "A-1"), section(2L, "A-2")) + .withAllLockers(usable(11L, 1L), mine) + .withAppliedLockerIds(21L) + .withMyLocker(mine); + + List sections = + new LockerServiceImpl(repository).getSections(PERIOD_ID, MEMBER_ID); + + assertFalse(sections.get(0).hasMine()); + assertTrue(sections.get(1).hasMine()); + } + + @Test + @DisplayName("신청이 없으면 어느 구역에도 hasMine이 붙지 않는다") + void noneWhenNotApplied() { + FakeLockerRepository repository = new FakeLockerRepository() + .withSections(section(1L, "A-1")) + .withAllLockers(usable(11L, 1L)); + + List sections = + new LockerServiceImpl(repository).getSections(PERIOD_ID, MEMBER_ID); + + assertFalse(sections.get(0).hasMine()); + } + + @Test + @DisplayName("게시되지 않은 회차는 찾을 수 없다") + void unpublishedPeriod() { + FakeLockerRepository repository = new FakeLockerRepository().withUnpublishedPeriod(); + + BusinessException e = assertThrows(BusinessException.class, + () -> new LockerServiceImpl(repository).getSections(PERIOD_ID, MEMBER_ID)); + + assertEquals(LockerErrorCode.LOCKER_PERIOD_NOT_FOUND, e.getErrorCode()); + } + } + + @Nested + @DisplayName("구역 상세") + class GetSectionLockers { + + @Test + @DisplayName("사용 중지됐거나 이미 신청된 사물함은 선택할 수 없다") + void availability() { + FakeLockerRepository repository = new FakeLockerRepository() + .withSectionLockers( + usable(11L, 1L), + locker(12L, 1L, LockerStatus.DISABLED), + usable(13L, 1L)) + .withAppliedLockerIds(13L); + + List lockers = + new LockerServiceImpl(repository).getSectionLockers(PERIOD_ID, 1L, MEMBER_ID); + + assertTrue(lockers.get(0).available()); // 사용 가능 + 미신청 + assertFalse(lockers.get(1).available()); // 사용 중지 + assertFalse(lockers.get(2).available()); // 이미 신청됨 + } + + @Test + @DisplayName("내 사물함에만 isMine이 붙고, 그 사물함은 선택 가능하지 않다") + void marksOnlyMyLocker() { + Locker mine = usable(13L, 1L); + FakeLockerRepository repository = new FakeLockerRepository() + .withSectionLockers(usable(11L, 1L), mine) + .withAppliedLockerIds(13L) + .withMyLocker(mine); + + List lockers = + new LockerServiceImpl(repository).getSectionLockers(PERIOD_ID, 1L, MEMBER_ID); + + assertFalse(lockers.get(0).mine()); + assertTrue(lockers.get(1).mine()); + assertFalse(lockers.get(1).available()); + } + + @Test + @DisplayName("없는 구역은 찾을 수 없다") + void unknownSection() { + FakeLockerRepository repository = new FakeLockerRepository().withMissingSection(); + + BusinessException e = assertThrows(BusinessException.class, + () -> new LockerServiceImpl(repository).getSectionLockers(PERIOD_ID, 99L, MEMBER_ID)); + + assertEquals(LockerErrorCode.LOCKER_SECTION_NOT_FOUND, e.getErrorCode()); + } + } + + /** 조회 결과만 답하고 집계·조합은 서비스가 하는지 보기 위한 가짜 레포지토리. */ + private static final class FakeLockerRepository implements LockerRepository { + + private boolean publishedPeriod = true; + private boolean sectionExists = true; + private List sections = List.of(); + private List allLockers = List.of(); + private List sectionLockers = List.of(); + private Set appliedLockerIds = Set.of(); + private Locker myLocker; + + FakeLockerRepository withSections(LockerSection... values) { + this.sections = List.of(values); + return this; + } + + FakeLockerRepository withAllLockers(Locker... values) { + this.allLockers = List.of(values); + return this; + } + + FakeLockerRepository withSectionLockers(Locker... values) { + this.sectionLockers = List.of(values); + return this; + } + + FakeLockerRepository withAppliedLockerIds(Long... values) { + this.appliedLockerIds = Set.of(values); + return this; + } + + FakeLockerRepository withMyLocker(Locker value) { + this.myLocker = value; + return this; + } + + FakeLockerRepository withUnpublishedPeriod() { + this.publishedPeriod = false; + return this; + } + + FakeLockerRepository withMissingSection() { + this.sectionExists = false; + return this; + } + + @Override + public boolean existsPublishedPeriod(Long lockerPeriodId) { + return publishedPeriod; + } + + @Override + public boolean existsSection(Long sectionId) { + return sectionExists; + } + + @Override + public List findAllSections() { + return sections; + } + + @Override + public List findAllLockers() { + return allLockers; + } + + @Override + public List findLockersBySectionId(Long sectionId) { + return sectionLockers; + } + + @Override + public Set findAppliedLockerIds(Long lockerPeriodId) { + return appliedLockerIds; + } + + @Override + public Optional findAppliedLockerId(Long lockerPeriodId, Long memberId) { + return Optional.ofNullable(myLocker).map(Locker::getId); + } + } +} From e6f8534c9372ee80b7ffc924b3c6713a46f9cb56 Mon Sep 17 00:00:00 2001 From: sangrae Date: Thu, 24 Sep 2026 01:38:43 +0900 Subject: [PATCH 04/14] =?UTF-8?q?feat:=20=EC=82=AC=EB=AC=BC=ED=95=A8=20?= =?UTF-8?q?=EA=B5=AC=EC=97=AD=20=EC=A1=B0=ED=9A=8C=20=EB=A0=88=ED=8F=AC?= =?UTF-8?q?=EC=A7=80=ED=86=A0=EB=A6=AC=20=EA=B5=AC=ED=98=84=EC=B2=B4=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 --- .../event/LockerApplicationJpaRepository.java | 31 ++++++++++ .../stream/db/event/LockerJpaRepository.java | 15 +++++ .../db/event/LockerPeriodJpaRepository.java | 8 +++ .../stream/db/event/LockerRepositoryImpl.java | 61 +++++++++++++++++++ .../db/event/LockerSectionJpaRepository.java | 12 ++++ 5 files changed, 127 insertions(+) create mode 100644 infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/LockerApplicationJpaRepository.java create mode 100644 infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/LockerJpaRepository.java create mode 100644 infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/LockerPeriodJpaRepository.java create mode 100644 infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/LockerRepositoryImpl.java create mode 100644 infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/LockerSectionJpaRepository.java diff --git a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/LockerApplicationJpaRepository.java b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/LockerApplicationJpaRepository.java new file mode 100644 index 00000000..a2838015 --- /dev/null +++ b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/LockerApplicationJpaRepository.java @@ -0,0 +1,31 @@ +package kr.ac.kookmin.stream.db.event; + +import java.util.Optional; +import java.util.Set; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +public interface LockerApplicationJpaRepository extends JpaRepository { + + /** + * 해당 운영 회차에 신청된 사물함 식별자. 신청 여부만 필요하므로 신청 자체는 읽지 않는다. + */ + @Query(""" + SELECT a.lockerId FROM LockerApplicationJpaEntity a + WHERE a.lockerPeriodId = :lockerPeriodId + """) + Set findLockerIdsByLockerPeriodId(@Param("lockerPeriodId") Long lockerPeriodId); + + /** + * 회차당 회원 신청은 유니크 제약으로 한 건까지만 존재한다. + */ + @Query(""" + SELECT a.lockerId FROM LockerApplicationJpaEntity a + WHERE a.lockerPeriodId = :lockerPeriodId AND a.memberId = :memberId + """) + Optional findLockerIdByLockerPeriodIdAndMemberId( + @Param("lockerPeriodId") Long lockerPeriodId, + @Param("memberId") Long memberId + ); +} diff --git a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/LockerJpaRepository.java b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/LockerJpaRepository.java new file mode 100644 index 00000000..ee808d30 --- /dev/null +++ b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/LockerJpaRepository.java @@ -0,0 +1,15 @@ +package kr.ac.kookmin.stream.db.event; + +import java.util.List; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface LockerJpaRepository extends JpaRepository { + + List findAllByIsDeletedFalse(); + + /** + * 배치도 순서(행 → 열)로 조회한다. (row_no, column_no)에 유니크 제약이 없어 같은 자리가 둘 이상일 수 있으므로 + * 식별자를 동점 기준으로 더해 같은 요청이 항상 같은 순서를 돌려주게 한다. + */ + List findAllBySectionIdAndIsDeletedFalseOrderByRowNoAscColumnNoAscIdAsc(Long sectionId); +} diff --git a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/LockerPeriodJpaRepository.java b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/LockerPeriodJpaRepository.java new file mode 100644 index 00000000..c06ffea6 --- /dev/null +++ b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/LockerPeriodJpaRepository.java @@ -0,0 +1,8 @@ +package kr.ac.kookmin.stream.db.event; + +import org.springframework.data.jpa.repository.JpaRepository; + +public interface LockerPeriodJpaRepository extends JpaRepository { + + boolean existsByIdAndIsPublishedTrue(Long id); +} diff --git a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/LockerRepositoryImpl.java b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/LockerRepositoryImpl.java new file mode 100644 index 00000000..e545e392 --- /dev/null +++ b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/LockerRepositoryImpl.java @@ -0,0 +1,61 @@ +package kr.ac.kookmin.stream.db.event; + +import java.util.List; +import java.util.Optional; +import java.util.Set; +import kr.ac.kookmin.stream.event.domain.locker.domain.Locker; +import kr.ac.kookmin.stream.event.domain.locker.domain.LockerSection; +import kr.ac.kookmin.stream.event.domain.locker.repository.LockerRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +@Repository +@RequiredArgsConstructor +public class LockerRepositoryImpl implements LockerRepository { + + private final LockerPeriodJpaRepository lockerPeriodJpaRepository; + private final LockerSectionJpaRepository lockerSectionJpaRepository; + private final LockerJpaRepository lockerJpaRepository; + private final LockerApplicationJpaRepository lockerApplicationJpaRepository; + + @Override + public boolean existsPublishedPeriod(Long lockerPeriodId) { + return lockerPeriodJpaRepository.existsByIdAndIsPublishedTrue(lockerPeriodId); + } + + @Override + public boolean existsSection(Long sectionId) { + return lockerSectionJpaRepository.existsById(sectionId); + } + + @Override + public List findAllSections() { + return lockerSectionJpaRepository.findAllByOrderByIdAsc().stream() + .map(LockerSectionJpaEntity::toDomain) + .toList(); + } + + @Override + public List findAllLockers() { + return lockerJpaRepository.findAllByIsDeletedFalse().stream() + .map(LockerJpaEntity::toDomain) + .toList(); + } + + @Override + public List findLockersBySectionId(Long sectionId) { + return lockerJpaRepository.findAllBySectionIdAndIsDeletedFalseOrderByRowNoAscColumnNoAscIdAsc(sectionId).stream() + .map(LockerJpaEntity::toDomain) + .toList(); + } + + @Override + public Set findAppliedLockerIds(Long lockerPeriodId) { + return lockerApplicationJpaRepository.findLockerIdsByLockerPeriodId(lockerPeriodId); + } + + @Override + public Optional findAppliedLockerId(Long lockerPeriodId, Long memberId) { + return lockerApplicationJpaRepository.findLockerIdByLockerPeriodIdAndMemberId(lockerPeriodId, memberId); + } +} diff --git a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/LockerSectionJpaRepository.java b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/LockerSectionJpaRepository.java new file mode 100644 index 00000000..284d78cd --- /dev/null +++ b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/LockerSectionJpaRepository.java @@ -0,0 +1,12 @@ +package kr.ac.kookmin.stream.db.event; + +import java.util.List; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface LockerSectionJpaRepository extends JpaRepository { + + /** + * 표시 순서를 따로 두지 않으므로 식별자로 정렬해 같은 요청이 항상 같은 순서를 돌려주게 한다. + */ + List findAllByOrderByIdAsc(); +} From 8f77ac0034e0c51017d1eddef9c92f6ba56d49ec Mon Sep 17 00:00:00 2001 From: sangrae Date: Thu, 24 Sep 2026 01:38:44 +0900 Subject: [PATCH 05/14] =?UTF-8?q?feat:=20=ED=95=99=EC=83=9D=20=EC=95=B1=20?= =?UTF-8?q?=EC=82=AC=EB=AC=BC=ED=95=A8=20=EA=B5=AC=EC=97=AD=20=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C=C2=B7=EA=B5=AC=EC=97=AD=20=EC=83=81=EC=84=B8=20?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C=20API=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../api/app/event/locker/AppLockerApi.java | 46 +++++++++++++++++++ .../app/event/locker/AppLockerController.java | 46 +++++++++++++++++++ .../request/LockerSectionListParams.java | 13 ++++++ .../locker/response/LockerLayoutResponse.java | 11 +++++ .../event/locker/response/LockerResponse.java | 26 +++++++++++ .../response/LockerSectionListResponse.java | 11 +++++ .../response/LockerSectionResponse.java | 25 ++++++++++ 7 files changed, 178 insertions(+) create mode 100644 api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/event/locker/AppLockerApi.java create mode 100644 api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/event/locker/AppLockerController.java create mode 100644 api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/event/locker/request/LockerSectionListParams.java create mode 100644 api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/event/locker/response/LockerLayoutResponse.java create mode 100644 api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/event/locker/response/LockerResponse.java create mode 100644 api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/event/locker/response/LockerSectionListResponse.java create mode 100644 api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/event/locker/response/LockerSectionResponse.java diff --git a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/event/locker/AppLockerApi.java b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/event/locker/AppLockerApi.java new file mode 100644 index 00000000..2c035019 --- /dev/null +++ b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/event/locker/AppLockerApi.java @@ -0,0 +1,46 @@ +package kr.ac.kookmin.stream.api.app.event.locker; + +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.event.locker.request.LockerSectionListParams; +import kr.ac.kookmin.stream.api.app.event.locker.response.LockerLayoutResponse; +import kr.ac.kookmin.stream.api.app.event.locker.response.LockerSectionListResponse; +import kr.ac.kookmin.stream.api.common.dto.ApiResponse; +import kr.ac.kookmin.stream.api.common.openapi.ApiErrorCode; +import kr.ac.kookmin.stream.common.CommonErrorCode; +import kr.ac.kookmin.stream.event.domain.locker.domain.LockerErrorCode; +import org.springdoc.core.annotations.ParameterObject; + +/** + * 학생 앱 사물함 API의 문서 명세. 구현은 {@link AppLockerController}가 맡는다. + *

+ * 스웨거 문서용 어노테이션만 이쪽에 두고 컨트롤러에는 라우팅과 본문만 남긴다. 경로 매핑과 + * 파라미터 바인딩(@{@code ModelAttribute}, @{@code PathVariable} 등)은 구현체에 둔다. + */ +@Tag(name = "사물함", description = "학생 앱 사물함 구역·배치 조회") +public interface AppLockerApi { + + /** 구역별 전체·선택 가능 사물함 수와 표시 상태. */ + @Operation(summary = "사물함 구역 목록 조회", + description = "운영 회차의 구역별 전체 사물함 수와 현재 선택 가능한 사물함 수를 조회한다. " + + "사용 중지된 사물함과 해당 회차에 이미 신청된 사물함은 선택 가능 수에서 빠진다.") + @ApiErrorCode(type = CommonErrorCode.class, codes = {"INVALID_INPUT"}) + @ApiErrorCode(type = LockerErrorCode.class, codes = {"LOCKER_PERIOD_NOT_FOUND"}) + ApiResponse getSections( + AppApiUser apiUser, + @ParameterObject LockerSectionListParams params + ); + + /** 구역에 속한 사물함의 배치 정보와 선택 가능 여부. */ + @Operation(summary = "사물함 구역 상세 조회", + description = "구역에 속한 사물함의 배치도 위치와 선택 가능 여부를 조회한다. " + + "사물함이 사용 가능한 상태이고 해당 회차에 신청되지 않은 경우에만 선택할 수 있다.") + @ApiErrorCode(type = CommonErrorCode.class, codes = {"INVALID_INPUT"}) + @ApiErrorCode(type = LockerErrorCode.class, codes = {"LOCKER_PERIOD_NOT_FOUND", "LOCKER_SECTION_NOT_FOUND"}) + ApiResponse getSectionLockers( + AppApiUser apiUser, + Long sectionId, + @ParameterObject LockerSectionListParams params + ); +} diff --git a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/event/locker/AppLockerController.java b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/event/locker/AppLockerController.java new file mode 100644 index 00000000..5d96aa3a --- /dev/null +++ b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/event/locker/AppLockerController.java @@ -0,0 +1,46 @@ +package kr.ac.kookmin.stream.api.app.event.locker; + +import jakarta.validation.Valid; +import kr.ac.kookmin.stream.api.app.AppApiUser; +import kr.ac.kookmin.stream.api.app.event.locker.request.LockerSectionListParams; +import kr.ac.kookmin.stream.api.app.event.locker.response.LockerLayoutResponse; +import kr.ac.kookmin.stream.api.app.event.locker.response.LockerSectionListResponse; +import kr.ac.kookmin.stream.api.common.dto.ApiResponse; +import kr.ac.kookmin.stream.event.domain.locker.service.LockerService; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.ModelAttribute; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/v1/app/lockers") +@RequiredArgsConstructor +public class AppLockerController implements AppLockerApi { + + private final LockerService lockerService; + + @Override + @GetMapping("/sections") + public ApiResponse getSections( + AppApiUser apiUser, + @Valid @ModelAttribute LockerSectionListParams params + ) { + return ApiResponse.success(LockerSectionListResponse.from( + lockerService.getSections(params.lockerPeriodId(), apiUser.userId()) + )); + } + + @Override + @GetMapping("/sections/{sectionId}") + public ApiResponse getSectionLockers( + AppApiUser apiUser, + @PathVariable Long sectionId, + @Valid @ModelAttribute LockerSectionListParams params + ) { + return ApiResponse.success(LockerLayoutResponse.from( + lockerService.getSectionLockers(params.lockerPeriodId(), sectionId, apiUser.userId()) + )); + } +} diff --git a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/event/locker/request/LockerSectionListParams.java b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/event/locker/request/LockerSectionListParams.java new file mode 100644 index 00000000..cf0af133 --- /dev/null +++ b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/event/locker/request/LockerSectionListParams.java @@ -0,0 +1,13 @@ +package kr.ac.kookmin.stream.api.app.event.locker.request; + +import jakarta.validation.constraints.NotNull; + +/** + * @param lockerPeriodId 조회할 사물함 운영 회차 식별자 + */ +public record LockerSectionListParams( + + @NotNull(message = "사물함 운영 회차를 입력해 주세요.") + Long lockerPeriodId +) { +} diff --git a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/event/locker/response/LockerLayoutResponse.java b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/event/locker/response/LockerLayoutResponse.java new file mode 100644 index 00000000..ac7f24c4 --- /dev/null +++ b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/event/locker/response/LockerLayoutResponse.java @@ -0,0 +1,11 @@ +package kr.ac.kookmin.stream.api.app.event.locker.response; + +import java.util.List; +import kr.ac.kookmin.stream.event.domain.locker.domain.LockerAvailability; + +public record LockerLayoutResponse(List lockers) { + + public static LockerLayoutResponse from(List lockers) { + return new LockerLayoutResponse(lockers.stream().map(LockerResponse::from).toList()); + } +} diff --git a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/event/locker/response/LockerResponse.java b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/event/locker/response/LockerResponse.java new file mode 100644 index 00000000..71c75db9 --- /dev/null +++ b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/event/locker/response/LockerResponse.java @@ -0,0 +1,26 @@ +package kr.ac.kookmin.stream.api.app.event.locker.response; + +import kr.ac.kookmin.stream.event.domain.locker.domain.LockerAvailability; + +public record LockerResponse( + Long lockerId, + String lockerLabel, + int lockerNumber, + int rowNo, + int columnNo, + boolean isAvailable, + boolean isMine +) { + + public static LockerResponse from(LockerAvailability locker) { + return new LockerResponse( + locker.lockerId(), + locker.lockerLabel(), + locker.lockerNumber(), + locker.rowNo(), + locker.columnNo(), + locker.available(), + locker.mine() + ); + } +} diff --git a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/event/locker/response/LockerSectionListResponse.java b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/event/locker/response/LockerSectionListResponse.java new file mode 100644 index 00000000..f0f8e8ab --- /dev/null +++ b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/event/locker/response/LockerSectionListResponse.java @@ -0,0 +1,11 @@ +package kr.ac.kookmin.stream.api.app.event.locker.response; + +import java.util.List; +import kr.ac.kookmin.stream.event.domain.locker.domain.LockerSectionSummary; + +public record LockerSectionListResponse(List sections) { + + public static LockerSectionListResponse from(List sections) { + return new LockerSectionListResponse(sections.stream().map(LockerSectionResponse::from).toList()); + } +} diff --git a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/event/locker/response/LockerSectionResponse.java b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/event/locker/response/LockerSectionResponse.java new file mode 100644 index 00000000..e943eb59 --- /dev/null +++ b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/event/locker/response/LockerSectionResponse.java @@ -0,0 +1,25 @@ +package kr.ac.kookmin.stream.api.app.event.locker.response; + +import kr.ac.kookmin.stream.event.domain.locker.domain.LockerSectionSummary; +import kr.ac.kookmin.stream.event.domain.locker.domain.SectionAvailabilityStatus; + +public record LockerSectionResponse( + Long sectionId, + String section, + int availableCount, + int totalCount, + SectionAvailabilityStatus availabilityStatus, + boolean hasMine +) { + + public static LockerSectionResponse from(LockerSectionSummary summary) { + return new LockerSectionResponse( + summary.sectionId(), + summary.label(), + summary.availableCount(), + summary.totalCount(), + summary.availabilityStatus(), + summary.hasMine() + ); + } +} From 4265b4c4274b9e7b82ec26eb8fddc35bfc13ff32 Mon Sep 17 00:00:00 2001 From: sangrae Date: Fri, 25 Sep 2026 16:25:58 +0900 Subject: [PATCH 06/14] =?UTF-8?q?refactor:=20=EC=82=AC=EB=AC=BC=ED=95=A8?= =?UTF-8?q?=20=EC=9D=BD=EA=B8=B0=20=EB=AA=A8=EB=8D=B8=EC=97=90=EC=84=9C=20?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C=ED=95=9C=20=ED=9A=8C=EC=9B=90=20=EA=B8=B0?= =?UTF-8?q?=EC=A4=80=20=ED=95=84=EB=93=9C=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 구역 요약과 사물함 가용 정보가 조회한 회원에 따라 달라지는 필드를 갖고 있어 응답 DTO와 1:1이 되고 다른 화면에서 재사용할 수 없었다. 읽기 모델에서 hasMine·mine을 빼 보는 사람과 무관하게 만들고, 신청한 사물함은 getMyLocker로 따로 조회해 표현 계층에서 맞춰보게 한다. --- .../app/event/locker/AppLockerController.java | 31 ++++- .../locker/response/LockerLayoutResponse.java | 9 +- .../event/locker/response/LockerResponse.java | 8 +- .../response/LockerSectionListResponse.java | 9 +- .../response/LockerSectionResponse.java | 8 +- .../locker/domain/LockerAvailability.java | 12 +- .../locker/domain/LockerSectionSummary.java | 12 +- .../locker/repository/LockerRepository.java | 8 +- .../domain/locker/service/LockerService.java | 14 +- .../service/impl/LockerServiceImpl.java | 32 ++--- .../service/impl/LockerServiceImplTest.java | 120 +++++++++--------- .../stream/db/event/LockerJpaRepository.java | 3 + .../stream/db/event/LockerRepositoryImpl.java | 6 + 13 files changed, 168 insertions(+), 104 deletions(-) diff --git a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/event/locker/AppLockerController.java b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/event/locker/AppLockerController.java index 5d96aa3a..60555e1a 100644 --- a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/event/locker/AppLockerController.java +++ b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/event/locker/AppLockerController.java @@ -1,11 +1,15 @@ package kr.ac.kookmin.stream.api.app.event.locker; import jakarta.validation.Valid; +import java.util.List; import kr.ac.kookmin.stream.api.app.AppApiUser; import kr.ac.kookmin.stream.api.app.event.locker.request.LockerSectionListParams; import kr.ac.kookmin.stream.api.app.event.locker.response.LockerLayoutResponse; import kr.ac.kookmin.stream.api.app.event.locker.response.LockerSectionListResponse; import kr.ac.kookmin.stream.api.common.dto.ApiResponse; +import kr.ac.kookmin.stream.event.domain.locker.domain.Locker; +import kr.ac.kookmin.stream.event.domain.locker.domain.LockerAvailability; +import kr.ac.kookmin.stream.event.domain.locker.domain.LockerSectionSummary; import kr.ac.kookmin.stream.event.domain.locker.service.LockerService; import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.GetMapping; @@ -14,6 +18,13 @@ import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; +/** + * 학생 앱의 사물함 구역·배치 조회 API. + *

+ * 구역과 사물함 조회 결과는 보는 사람과 무관하다. 내 사물함 표시는 그 결과에 회원의 신청 사물함을 + * 맞춰봐서 여기서 만든다. 조회 순서를 지켜야 한다. 미게시 회차·없는 구역 판정은 구역 조회가 하므로 + * 그쪽을 먼저 호출해야 404가 신청 조회보다 앞선다. + */ @RestController @RequestMapping("/v1/app/lockers") @RequiredArgsConstructor @@ -27,9 +38,13 @@ public ApiResponse getSections( AppApiUser apiUser, @Valid @ModelAttribute LockerSectionListParams params ) { - return ApiResponse.success(LockerSectionListResponse.from( - lockerService.getSections(params.lockerPeriodId(), apiUser.userId()) - )); + Long lockerPeriodId = params.lockerPeriodId(); + List sections = lockerService.getSections(lockerPeriodId); + Long mySectionId = lockerService.getMyLocker(lockerPeriodId, apiUser.userId()) + .map(Locker::getSectionId) + .orElse(null); + + return ApiResponse.success(LockerSectionListResponse.of(sections, mySectionId)); } @Override @@ -39,8 +54,12 @@ public ApiResponse getSectionLockers( @PathVariable Long sectionId, @Valid @ModelAttribute LockerSectionListParams params ) { - return ApiResponse.success(LockerLayoutResponse.from( - lockerService.getSectionLockers(params.lockerPeriodId(), sectionId, apiUser.userId()) - )); + Long lockerPeriodId = params.lockerPeriodId(); + List lockers = lockerService.getSectionLockers(lockerPeriodId, sectionId); + Long myLockerId = lockerService.getMyLocker(lockerPeriodId, apiUser.userId()) + .map(Locker::getId) + .orElse(null); + + return ApiResponse.success(LockerLayoutResponse.of(lockers, myLockerId)); } } diff --git a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/event/locker/response/LockerLayoutResponse.java b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/event/locker/response/LockerLayoutResponse.java index ac7f24c4..8f119820 100644 --- a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/event/locker/response/LockerLayoutResponse.java +++ b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/event/locker/response/LockerLayoutResponse.java @@ -5,7 +5,12 @@ public record LockerLayoutResponse(List lockers) { - public static LockerLayoutResponse from(List lockers) { - return new LockerLayoutResponse(lockers.stream().map(LockerResponse::from).toList()); + /** + * @param myLockerId 조회한 회원이 신청한 사물함. 신청하지 않았으면 {@code null} + */ + public static LockerLayoutResponse of(List lockers, Long myLockerId) { + return new LockerLayoutResponse(lockers.stream() + .map(locker -> LockerResponse.of(locker, myLockerId)) + .toList()); } } diff --git a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/event/locker/response/LockerResponse.java b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/event/locker/response/LockerResponse.java index 71c75db9..b88a0781 100644 --- a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/event/locker/response/LockerResponse.java +++ b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/event/locker/response/LockerResponse.java @@ -1,5 +1,6 @@ package kr.ac.kookmin.stream.api.app.event.locker.response; +import java.util.Objects; import kr.ac.kookmin.stream.event.domain.locker.domain.LockerAvailability; public record LockerResponse( @@ -12,7 +13,10 @@ public record LockerResponse( boolean isMine ) { - public static LockerResponse from(LockerAvailability locker) { + /** + * @param myLockerId 조회한 회원이 신청한 사물함. 신청하지 않았으면 {@code null} + */ + public static LockerResponse of(LockerAvailability locker, Long myLockerId) { return new LockerResponse( locker.lockerId(), locker.lockerLabel(), @@ -20,7 +24,7 @@ public static LockerResponse from(LockerAvailability locker) { locker.rowNo(), locker.columnNo(), locker.available(), - locker.mine() + Objects.equals(locker.lockerId(), myLockerId) ); } } diff --git a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/event/locker/response/LockerSectionListResponse.java b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/event/locker/response/LockerSectionListResponse.java index f0f8e8ab..bf20588e 100644 --- a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/event/locker/response/LockerSectionListResponse.java +++ b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/event/locker/response/LockerSectionListResponse.java @@ -5,7 +5,12 @@ public record LockerSectionListResponse(List sections) { - public static LockerSectionListResponse from(List sections) { - return new LockerSectionListResponse(sections.stream().map(LockerSectionResponse::from).toList()); + /** + * @param mySectionId 조회한 회원이 신청한 사물함이 속한 구역. 신청하지 않았으면 {@code null} + */ + public static LockerSectionListResponse of(List sections, Long mySectionId) { + return new LockerSectionListResponse(sections.stream() + .map(summary -> LockerSectionResponse.of(summary, mySectionId)) + .toList()); } } diff --git a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/event/locker/response/LockerSectionResponse.java b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/event/locker/response/LockerSectionResponse.java index e943eb59..422922ce 100644 --- a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/event/locker/response/LockerSectionResponse.java +++ b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/event/locker/response/LockerSectionResponse.java @@ -1,5 +1,6 @@ package kr.ac.kookmin.stream.api.app.event.locker.response; +import java.util.Objects; import kr.ac.kookmin.stream.event.domain.locker.domain.LockerSectionSummary; import kr.ac.kookmin.stream.event.domain.locker.domain.SectionAvailabilityStatus; @@ -12,14 +13,17 @@ public record LockerSectionResponse( boolean hasMine ) { - public static LockerSectionResponse from(LockerSectionSummary summary) { + /** + * @param mySectionId 조회한 회원이 신청한 사물함이 속한 구역. 신청하지 않았으면 {@code null} + */ + public static LockerSectionResponse of(LockerSectionSummary summary, Long mySectionId) { return new LockerSectionResponse( summary.sectionId(), summary.label(), summary.availableCount(), summary.totalCount(), summary.availabilityStatus(), - summary.hasMine() + Objects.equals(summary.sectionId(), mySectionId) ); } } diff --git a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/domain/LockerAvailability.java b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/domain/LockerAvailability.java index 4cdd2e06..3f0cca12 100644 --- a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/domain/LockerAvailability.java +++ b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/domain/LockerAvailability.java @@ -2,6 +2,9 @@ /** * 배치도에 그릴 사물함 한 건. 선택 가능 여부는 사물함 자체 상태와 해당 운영 회차의 신청 여부를 함께 본다. + *

+ * 조회한 회원에 따라 달라지는 값은 담지 않는다. 내 사물함 표시처럼 보는 사람이 기준인 값은 이 결과와 + * {@code LockerService#getMyLocker}를 조합해 표현 계층에서 만든다. */ public record LockerAvailability( Long lockerId, @@ -9,23 +12,20 @@ public record LockerAvailability( int lockerNumber, int rowNo, int columnNo, - boolean available, - boolean mine + boolean available ) { /** * @param applied 해당 운영 회차에 이 사물함이 이미 신청되었는지 - * @param mine 그 신청이 조회한 회원의 것인지 */ - public static LockerAvailability of(Locker locker, boolean applied, boolean mine) { + public static LockerAvailability of(Locker locker, boolean applied) { return new LockerAvailability( locker.getId(), locker.getLockerLabel(), locker.getLockerNumber(), locker.getRowNo(), locker.getColumnNo(), - locker.isSelectable(applied), - mine + locker.isSelectable(applied) ); } } diff --git a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/domain/LockerSectionSummary.java b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/domain/LockerSectionSummary.java index 7e01aa68..42b73738 100644 --- a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/domain/LockerSectionSummary.java +++ b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/domain/LockerSectionSummary.java @@ -5,14 +5,16 @@ /** * 사물함 구역 목록 한 건. 전체·선택 가능 수와 표시 상태는 저장값이 아니라 조회 시점에 센다. + *

+ * 조회한 회원에 따라 달라지는 값은 담지 않는다. 내 사물함이 있는 구역인지는 이 결과와 + * {@code LockerService#getMyLocker}의 구역을 맞춰보면 나온다. */ public record LockerSectionSummary( Long sectionId, String label, int availableCount, int totalCount, - SectionAvailabilityStatus availabilityStatus, - boolean hasMine + SectionAvailabilityStatus availabilityStatus ) { /** @@ -22,8 +24,7 @@ public record LockerSectionSummary( public static LockerSectionSummary of( LockerSection section, List lockers, - Set appliedLockerIds, - boolean hasMine + Set appliedLockerIds ) { int totalCount = lockers.size(); int availableCount = (int) lockers.stream() @@ -35,8 +36,7 @@ public static LockerSectionSummary of( section.getLabel(), availableCount, totalCount, - SectionAvailabilityStatus.from(availableCount, totalCount), - hasMine + SectionAvailabilityStatus.from(availableCount, totalCount) ); } } diff --git a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/repository/LockerRepository.java b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/repository/LockerRepository.java index 9b06af6c..7ca63b11 100644 --- a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/repository/LockerRepository.java +++ b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/repository/LockerRepository.java @@ -37,6 +37,11 @@ public interface LockerRepository { */ List findLockersBySectionId(Long sectionId); + /** + * 사물함 한 건. 삭제된 사물함은 없는 것으로 본다. + */ + Optional findLockerById(Long lockerId); + /** * 해당 운영 회차에 이미 신청된 사물함 식별자. */ @@ -45,7 +50,8 @@ public interface LockerRepository { /** * 해당 운영 회차에서 회원이 신청한 사물함 식별자. 회차당 한 건만 신청할 수 있고 취소가 없어 최대 하나다. *

- * 어느 구역인지는 이미 읽어둔 사물함 목록에서 가려낼 수 있어 식별자만 돌려준다. + * 사물함 값이 필요하면 {@link #findLockerById(Long)}로 이어 읽는다. 신청과 사물함을 조인해 한 번에 + * 읽지 않는 이유는 이 포트의 조회를 전부 단일 테이블 조회로 두려는 것이다. */ Optional findAppliedLockerId(Long lockerPeriodId, Long memberId); } diff --git a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/service/LockerService.java b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/service/LockerService.java index 7d0e524a..59480722 100644 --- a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/service/LockerService.java +++ b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/service/LockerService.java @@ -1,6 +1,8 @@ package kr.ac.kookmin.stream.event.domain.locker.service; import java.util.List; +import java.util.Optional; +import kr.ac.kookmin.stream.event.domain.locker.domain.Locker; import kr.ac.kookmin.stream.event.domain.locker.domain.LockerAvailability; import kr.ac.kookmin.stream.event.domain.locker.domain.LockerSectionSummary; @@ -9,10 +11,18 @@ public interface LockerService { /** * 운영 회차의 구역별 전체/선택 가능 사물함 수와 표시 상태를 조회한다. */ - List getSections(Long lockerPeriodId, Long memberId); + List getSections(Long lockerPeriodId); /** * 구역에 속한 사물함의 배치 정보와 선택 가능 여부를 조회한다. */ - List getSectionLockers(Long lockerPeriodId, Long sectionId, Long memberId); + List getSectionLockers(Long lockerPeriodId, Long sectionId); + + /** + * 해당 운영 회차에서 회원이 신청한 사물함. 회차당 한 건만 신청할 수 있고 취소가 없어 최대 하나다. + *

+ * 회차 게시 여부는 확인하지 않는다. 회원 본인의 신청은 공개 여부와 무관하게 본인 것이고, 조회 + * 엔드포인트는 구역 조회에서 이미 미게시 회차를 걸러낸 뒤 이 메서드를 쓴다. + */ + Optional getMyLocker(Long lockerPeriodId, Long memberId); } diff --git a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/service/impl/LockerServiceImpl.java b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/service/impl/LockerServiceImpl.java index f9994ea7..54beff12 100644 --- a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/service/impl/LockerServiceImpl.java +++ b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/service/impl/LockerServiceImpl.java @@ -2,6 +2,7 @@ import java.util.List; import java.util.Map; +import java.util.Optional; import java.util.Set; import java.util.stream.Collectors; import kr.ac.kookmin.stream.common.BusinessException; @@ -23,46 +24,41 @@ class LockerServiceImpl implements LockerService { @Override @Transactional(readOnly = true) - public List getSections(Long lockerPeriodId, Long memberId) { + public List getSections(Long lockerPeriodId) { requirePublishedPeriod(lockerPeriodId); Set appliedLockerIds = lockerRepository.findAppliedLockerIds(lockerPeriodId); Map> lockersBySection = lockerRepository.findAllLockers().stream() .collect(Collectors.groupingBy(Locker::getSectionId)); - Long myLockerId = lockerRepository.findAppliedLockerId(lockerPeriodId, memberId).orElse(null); - return lockerRepository.findAllSections().stream() - .map(section -> { + .map(section -> LockerSectionSummary.of( + section, // 사물함이 한 건도 없는 구역은 묶음에 키가 없다. 빈 목록으로 채워 목록에서 빠지지 않게 한다 - List sectionLockers = lockersBySection.getOrDefault(section.getId(), List.of()); - return LockerSectionSummary.of( - section, sectionLockers, appliedLockerIds, containsMine(sectionLockers, myLockerId)); - }) + lockersBySection.getOrDefault(section.getId(), List.of()), + appliedLockerIds + )) .toList(); } @Override @Transactional(readOnly = true) - public List getSectionLockers(Long lockerPeriodId, Long sectionId, Long memberId) { + public List getSectionLockers(Long lockerPeriodId, Long sectionId) { requirePublishedPeriod(lockerPeriodId); requireSection(sectionId); Set appliedLockerIds = lockerRepository.findAppliedLockerIds(lockerPeriodId); - Long myLockerId = lockerRepository.findAppliedLockerId(lockerPeriodId, memberId).orElse(null); return lockerRepository.findLockersBySectionId(sectionId).stream() - .map(locker -> LockerAvailability.of( - locker, - appliedLockerIds.contains(locker.getId()), - locker.getId().equals(myLockerId) - )) + .map(locker -> LockerAvailability.of(locker, appliedLockerIds.contains(locker.getId()))) .toList(); } - /** 내 사물함이 이 구역에 있는지. 회차당 신청이 최대 한 건이라 참이 되는 구역도 하나뿐이다. */ - private boolean containsMine(List lockers, Long myLockerId) { - return myLockerId != null && lockers.stream().anyMatch(locker -> myLockerId.equals(locker.getId())); + @Override + @Transactional(readOnly = true) + public Optional getMyLocker(Long lockerPeriodId, Long memberId) { + return lockerRepository.findAppliedLockerId(lockerPeriodId, memberId) + .flatMap(lockerRepository::findLockerById); } /** 아직 게시하지 않은 회차는 학생에게 없는 것으로 보여야 하므로 두 조회의 입구에서 같은 기준으로 거른다. */ diff --git a/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/locker/service/impl/LockerServiceImplTest.java b/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/locker/service/impl/LockerServiceImplTest.java index 1b6a1f30..008098b3 100644 --- a/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/locker/service/impl/LockerServiceImplTest.java +++ b/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/locker/service/impl/LockerServiceImplTest.java @@ -8,6 +8,7 @@ import java.util.List; import java.util.Optional; import java.util.Set; +import java.util.stream.Stream; import kr.ac.kookmin.stream.common.BusinessException; import kr.ac.kookmin.stream.event.domain.locker.domain.Locker; import kr.ac.kookmin.stream.event.domain.locker.domain.LockerAvailability; @@ -22,7 +23,9 @@ import org.junit.jupiter.api.Test; /** - * 구역·사물함·신청을 각각 조회해 서비스가 구역별로 묶어 센다. 그 집계와 조합이 어긋나지 않는지 확인한다. + * 구역·사물함·신청을 각각 조회해 서비스가 구역별로 묶어 센다. 그 집계가 어긋나지 않는지 확인한다. + *

+ * 내 사물함 표시는 표현 계층이 {@code getMyLocker} 결과를 맞춰봐서 만들므로 여기서는 그 조회만 본다. */ class LockerServiceImplTest { @@ -57,8 +60,7 @@ void countsPerSection() { usable(21L, 2L)) .withAppliedLockerIds(13L); - List sections = - new LockerServiceImpl(repository).getSections(PERIOD_ID, MEMBER_ID); + List sections = new LockerServiceImpl(repository).getSections(PERIOD_ID); assertEquals(List.of(1L, 2L), sections.stream().map(LockerSectionSummary::sectionId).toList()); assertEquals(3, sections.get(0).totalCount()); @@ -75,51 +77,20 @@ void keepsSectionsWithoutLockers() { .withSections(section(1L, "A-1"), section(2L, "A-2")) .withAllLockers(usable(11L, 1L)); - List sections = - new LockerServiceImpl(repository).getSections(PERIOD_ID, MEMBER_ID); + List sections = new LockerServiceImpl(repository).getSections(PERIOD_ID); assertEquals(2, sections.size()); assertEquals(0, sections.get(1).totalCount()); assertEquals(SectionAvailabilityStatus.FULL, sections.get(1).availabilityStatus()); } - @Test - @DisplayName("내 사물함이 있는 구역에만 hasMine이 붙는다") - void marksOnlyMySection() { - Locker mine = usable(21L, 2L); - FakeLockerRepository repository = new FakeLockerRepository() - .withSections(section(1L, "A-1"), section(2L, "A-2")) - .withAllLockers(usable(11L, 1L), mine) - .withAppliedLockerIds(21L) - .withMyLocker(mine); - - List sections = - new LockerServiceImpl(repository).getSections(PERIOD_ID, MEMBER_ID); - - assertFalse(sections.get(0).hasMine()); - assertTrue(sections.get(1).hasMine()); - } - - @Test - @DisplayName("신청이 없으면 어느 구역에도 hasMine이 붙지 않는다") - void noneWhenNotApplied() { - FakeLockerRepository repository = new FakeLockerRepository() - .withSections(section(1L, "A-1")) - .withAllLockers(usable(11L, 1L)); - - List sections = - new LockerServiceImpl(repository).getSections(PERIOD_ID, MEMBER_ID); - - assertFalse(sections.get(0).hasMine()); - } - @Test @DisplayName("게시되지 않은 회차는 찾을 수 없다") void unpublishedPeriod() { FakeLockerRepository repository = new FakeLockerRepository().withUnpublishedPeriod(); BusinessException e = assertThrows(BusinessException.class, - () -> new LockerServiceImpl(repository).getSections(PERIOD_ID, MEMBER_ID)); + () -> new LockerServiceImpl(repository).getSections(PERIOD_ID)); assertEquals(LockerErrorCode.LOCKER_PERIOD_NOT_FOUND, e.getErrorCode()); } @@ -140,7 +111,7 @@ void availability() { .withAppliedLockerIds(13L); List lockers = - new LockerServiceImpl(repository).getSectionLockers(PERIOD_ID, 1L, MEMBER_ID); + new LockerServiceImpl(repository).getSectionLockers(PERIOD_ID, 1L); assertTrue(lockers.get(0).available()); // 사용 가능 + 미신청 assertFalse(lockers.get(1).available()); // 사용 중지 @@ -148,35 +119,58 @@ void availability() { } @Test - @DisplayName("내 사물함에만 isMine이 붙고, 그 사물함은 선택 가능하지 않다") - void marksOnlyMyLocker() { - Locker mine = usable(13L, 1L); + @DisplayName("없는 구역은 찾을 수 없다") + void unknownSection() { + FakeLockerRepository repository = new FakeLockerRepository().withMissingSection(); + + BusinessException e = assertThrows(BusinessException.class, + () -> new LockerServiceImpl(repository).getSectionLockers(PERIOD_ID, 99L)); + + assertEquals(LockerErrorCode.LOCKER_SECTION_NOT_FOUND, e.getErrorCode()); + } + } + + @Nested + @DisplayName("내 사물함") + class GetMyLocker { + + @Test + @DisplayName("신청한 사물함을 구역까지 담아 돌려준다") + void returnsAppliedLocker() { + Locker mine = usable(21L, 2L); FakeLockerRepository repository = new FakeLockerRepository() - .withSectionLockers(usable(11L, 1L), mine) - .withAppliedLockerIds(13L) + .withAllLockers(usable(11L, 1L), mine) .withMyLocker(mine); - List lockers = - new LockerServiceImpl(repository).getSectionLockers(PERIOD_ID, 1L, MEMBER_ID); + Optional found = new LockerServiceImpl(repository).getMyLocker(PERIOD_ID, MEMBER_ID); - assertFalse(lockers.get(0).mine()); - assertTrue(lockers.get(1).mine()); - assertFalse(lockers.get(1).available()); + assertTrue(found.isPresent()); + assertEquals(21L, found.get().getId()); + assertEquals(2L, found.get().getSectionId()); } @Test - @DisplayName("없는 구역은 찾을 수 없다") - void unknownSection() { - FakeLockerRepository repository = new FakeLockerRepository().withMissingSection(); + @DisplayName("신청하지 않았으면 비어 있다") + void emptyWhenNotApplied() { + FakeLockerRepository repository = new FakeLockerRepository() + .withAllLockers(usable(11L, 1L)); - BusinessException e = assertThrows(BusinessException.class, - () -> new LockerServiceImpl(repository).getSectionLockers(PERIOD_ID, 99L, MEMBER_ID)); + assertTrue(new LockerServiceImpl(repository).getMyLocker(PERIOD_ID, MEMBER_ID).isEmpty()); + } - assertEquals(LockerErrorCode.LOCKER_SECTION_NOT_FOUND, e.getErrorCode()); + @Test + @DisplayName("신청한 사물함이 삭제됐으면 비어 있다") + void emptyWhenLockerRemoved() { + // 신청 행은 남아 있는데 사물함이 삭제된 경우. 사물함 조회가 삭제된 건을 거르므로 여기서도 없는 것이 된다 + FakeLockerRepository repository = new FakeLockerRepository() + .withAllLockers(usable(11L, 1L)) + .withMyLockerId(99L); + + assertTrue(new LockerServiceImpl(repository).getMyLocker(PERIOD_ID, MEMBER_ID).isEmpty()); } } - /** 조회 결과만 답하고 집계·조합은 서비스가 하는지 보기 위한 가짜 레포지토리. */ + /** 조회 결과만 답하고 집계는 서비스가 하는지 보기 위한 가짜 레포지토리. */ private static final class FakeLockerRepository implements LockerRepository { private boolean publishedPeriod = true; @@ -185,7 +179,7 @@ private static final class FakeLockerRepository implements LockerRepository { private List allLockers = List.of(); private List sectionLockers = List.of(); private Set appliedLockerIds = Set.of(); - private Locker myLocker; + private Long myLockerId; FakeLockerRepository withSections(LockerSection... values) { this.sections = List.of(values); @@ -208,7 +202,12 @@ FakeLockerRepository withAppliedLockerIds(Long... values) { } FakeLockerRepository withMyLocker(Locker value) { - this.myLocker = value; + return withMyLockerId(value.getId()); + } + + /** 사물함 목록에 없는 식별자를 넣으면 신청 행만 남고 사물함은 삭제된 상황이 된다. */ + FakeLockerRepository withMyLockerId(Long value) { + this.myLockerId = value; return this; } @@ -247,6 +246,13 @@ public List findLockersBySectionId(Long sectionId) { return sectionLockers; } + @Override + public Optional findLockerById(Long lockerId) { + return Stream.concat(allLockers.stream(), sectionLockers.stream()) + .filter(locker -> locker.getId().equals(lockerId)) + .findFirst(); + } + @Override public Set findAppliedLockerIds(Long lockerPeriodId) { return appliedLockerIds; @@ -254,7 +260,7 @@ public Set findAppliedLockerIds(Long lockerPeriodId) { @Override public Optional findAppliedLockerId(Long lockerPeriodId, Long memberId) { - return Optional.ofNullable(myLocker).map(Locker::getId); + return Optional.ofNullable(myLockerId); } } } diff --git a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/LockerJpaRepository.java b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/LockerJpaRepository.java index ee808d30..6ed2124e 100644 --- a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/LockerJpaRepository.java +++ b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/LockerJpaRepository.java @@ -1,12 +1,15 @@ package kr.ac.kookmin.stream.db.event; import java.util.List; +import java.util.Optional; import org.springframework.data.jpa.repository.JpaRepository; public interface LockerJpaRepository extends JpaRepository { List findAllByIsDeletedFalse(); + Optional findByIdAndIsDeletedFalse(Long id); + /** * 배치도 순서(행 → 열)로 조회한다. (row_no, column_no)에 유니크 제약이 없어 같은 자리가 둘 이상일 수 있으므로 * 식별자를 동점 기준으로 더해 같은 요청이 항상 같은 순서를 돌려주게 한다. diff --git a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/LockerRepositoryImpl.java b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/LockerRepositoryImpl.java index e545e392..38dd8fcf 100644 --- a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/LockerRepositoryImpl.java +++ b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/LockerRepositoryImpl.java @@ -49,6 +49,12 @@ public List findLockersBySectionId(Long sectionId) { .toList(); } + @Override + public Optional findLockerById(Long lockerId) { + return lockerJpaRepository.findByIdAndIsDeletedFalse(lockerId) + .map(LockerJpaEntity::toDomain); + } + @Override public Set findAppliedLockerIds(Long lockerPeriodId) { return lockerApplicationJpaRepository.findLockerIdsByLockerPeriodId(lockerPeriodId); From ec485f36f4137427b5a637aadb2bfae04738dcc5 Mon Sep 17 00:00:00 2001 From: sangrae Date: Fri, 25 Sep 2026 16:36:02 +0900 Subject: [PATCH 07/14] =?UTF-8?q?refactor:=20getMyLocker=EB=A5=BC=20getLoc?= =?UTF-8?q?kerByMemberId=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 회원 식별자를 인자로 받는데 이름이 "my"라 호출하는 쪽이 누구든 자기 사물함을 보는 것처럼 읽혔다. 운영진 화면처럼 다른 회원의 배정을 확인하는 곳도 같은 메서드를 쓰므로 조회 기준을 이름에 드러낸다. --- .../api/app/event/locker/AppLockerController.java | 4 ++-- .../domain/locker/domain/LockerAvailability.java | 4 ++-- .../domain/locker/domain/LockerSectionSummary.java | 2 +- .../event/domain/locker/service/LockerService.java | 10 +++++++--- .../locker/service/impl/LockerServiceImpl.java | 2 +- .../locker/service/impl/LockerServiceImplTest.java | 12 ++++++------ 6 files changed, 19 insertions(+), 15 deletions(-) diff --git a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/event/locker/AppLockerController.java b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/event/locker/AppLockerController.java index 60555e1a..6a265060 100644 --- a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/event/locker/AppLockerController.java +++ b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/event/locker/AppLockerController.java @@ -40,7 +40,7 @@ public ApiResponse getSections( ) { Long lockerPeriodId = params.lockerPeriodId(); List sections = lockerService.getSections(lockerPeriodId); - Long mySectionId = lockerService.getMyLocker(lockerPeriodId, apiUser.userId()) + Long mySectionId = lockerService.getLockerByMemberId(lockerPeriodId, apiUser.userId()) .map(Locker::getSectionId) .orElse(null); @@ -56,7 +56,7 @@ public ApiResponse getSectionLockers( ) { Long lockerPeriodId = params.lockerPeriodId(); List lockers = lockerService.getSectionLockers(lockerPeriodId, sectionId); - Long myLockerId = lockerService.getMyLocker(lockerPeriodId, apiUser.userId()) + Long myLockerId = lockerService.getLockerByMemberId(lockerPeriodId, apiUser.userId()) .map(Locker::getId) .orElse(null); diff --git a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/domain/LockerAvailability.java b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/domain/LockerAvailability.java index 3f0cca12..5c767b2a 100644 --- a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/domain/LockerAvailability.java +++ b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/domain/LockerAvailability.java @@ -3,8 +3,8 @@ /** * 배치도에 그릴 사물함 한 건. 선택 가능 여부는 사물함 자체 상태와 해당 운영 회차의 신청 여부를 함께 본다. *

- * 조회한 회원에 따라 달라지는 값은 담지 않는다. 내 사물함 표시처럼 보는 사람이 기준인 값은 이 결과와 - * {@code LockerService#getMyLocker}를 조합해 표현 계층에서 만든다. + * 조회한 회원에 따라 달라지는 값은 담지 않는다. 내 사물함 표시처럼 보는 사람이 기준인 값은 이 결과에 + * {@code LockerService#getLockerByMemberId}로 읽은 사물함을 맞춰봐서 표현 계층에서 만든다. */ public record LockerAvailability( Long lockerId, diff --git a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/domain/LockerSectionSummary.java b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/domain/LockerSectionSummary.java index 42b73738..dbcd4a79 100644 --- a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/domain/LockerSectionSummary.java +++ b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/domain/LockerSectionSummary.java @@ -7,7 +7,7 @@ * 사물함 구역 목록 한 건. 전체·선택 가능 수와 표시 상태는 저장값이 아니라 조회 시점에 센다. *

* 조회한 회원에 따라 달라지는 값은 담지 않는다. 내 사물함이 있는 구역인지는 이 결과와 - * {@code LockerService#getMyLocker}의 구역을 맞춰보면 나온다. + * {@code LockerService#getLockerByMemberId}의 구역을 맞춰보면 나온다. */ public record LockerSectionSummary( Long sectionId, diff --git a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/service/LockerService.java b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/service/LockerService.java index 59480722..f2e62d7b 100644 --- a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/service/LockerService.java +++ b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/service/LockerService.java @@ -21,8 +21,12 @@ public interface LockerService { /** * 해당 운영 회차에서 회원이 신청한 사물함. 회차당 한 건만 신청할 수 있고 취소가 없어 최대 하나다. *

- * 회차 게시 여부는 확인하지 않는다. 회원 본인의 신청은 공개 여부와 무관하게 본인 것이고, 조회 - * 엔드포인트는 구역 조회에서 이미 미게시 회차를 걸러낸 뒤 이 메서드를 쓴다. + * 조회하는 쪽이 자기 사물함을 보는지 남의 것을 보는지 가리지 않는다. 학생 앱은 요청한 회원의 + * 식별자를 넘겨 "내 사물함"을 표시하고, 운영진 화면처럼 다른 회원의 배정을 확인해야 하는 곳도 + * 같은 메서드를 쓴다. 노출 범위 판단은 호출하는 쪽의 몫이다. + *

+ * 회차 게시 여부는 확인하지 않는다. 신청은 회차 공개 여부와 무관하게 이미 이뤄진 사실이고, 학생 앱 + * 조회 엔드포인트는 구역 조회에서 미게시 회차를 먼저 404로 거른 뒤 이 메서드를 쓴다. */ - Optional getMyLocker(Long lockerPeriodId, Long memberId); + Optional getLockerByMemberId(Long lockerPeriodId, Long memberId); } diff --git a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/service/impl/LockerServiceImpl.java b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/service/impl/LockerServiceImpl.java index 54beff12..d85e5fa9 100644 --- a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/service/impl/LockerServiceImpl.java +++ b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/service/impl/LockerServiceImpl.java @@ -56,7 +56,7 @@ public List getSectionLockers(Long lockerPeriodId, Long sect @Override @Transactional(readOnly = true) - public Optional getMyLocker(Long lockerPeriodId, Long memberId) { + public Optional getLockerByMemberId(Long lockerPeriodId, Long memberId) { return lockerRepository.findAppliedLockerId(lockerPeriodId, memberId) .flatMap(lockerRepository::findLockerById); } diff --git a/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/locker/service/impl/LockerServiceImplTest.java b/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/locker/service/impl/LockerServiceImplTest.java index 008098b3..0e2a1df9 100644 --- a/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/locker/service/impl/LockerServiceImplTest.java +++ b/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/locker/service/impl/LockerServiceImplTest.java @@ -25,7 +25,7 @@ /** * 구역·사물함·신청을 각각 조회해 서비스가 구역별로 묶어 센다. 그 집계가 어긋나지 않는지 확인한다. *

- * 내 사물함 표시는 표현 계층이 {@code getMyLocker} 결과를 맞춰봐서 만들므로 여기서는 그 조회만 본다. + * 내 사물함 표시는 표현 계층이 {@code getLockerByMemberId} 결과를 맞춰봐서 만들므로 여기서는 그 조회만 본다. */ class LockerServiceImplTest { @@ -131,8 +131,8 @@ void unknownSection() { } @Nested - @DisplayName("내 사물함") - class GetMyLocker { + @DisplayName("회원이 신청한 사물함") + class GetLockerByMemberId { @Test @DisplayName("신청한 사물함을 구역까지 담아 돌려준다") @@ -142,7 +142,7 @@ void returnsAppliedLocker() { .withAllLockers(usable(11L, 1L), mine) .withMyLocker(mine); - Optional found = new LockerServiceImpl(repository).getMyLocker(PERIOD_ID, MEMBER_ID); + Optional found = new LockerServiceImpl(repository).getLockerByMemberId(PERIOD_ID, MEMBER_ID); assertTrue(found.isPresent()); assertEquals(21L, found.get().getId()); @@ -155,7 +155,7 @@ void emptyWhenNotApplied() { FakeLockerRepository repository = new FakeLockerRepository() .withAllLockers(usable(11L, 1L)); - assertTrue(new LockerServiceImpl(repository).getMyLocker(PERIOD_ID, MEMBER_ID).isEmpty()); + assertTrue(new LockerServiceImpl(repository).getLockerByMemberId(PERIOD_ID, MEMBER_ID).isEmpty()); } @Test @@ -166,7 +166,7 @@ void emptyWhenLockerRemoved() { .withAllLockers(usable(11L, 1L)) .withMyLockerId(99L); - assertTrue(new LockerServiceImpl(repository).getMyLocker(PERIOD_ID, MEMBER_ID).isEmpty()); + assertTrue(new LockerServiceImpl(repository).getLockerByMemberId(PERIOD_ID, MEMBER_ID).isEmpty()); } } From 1f4eb68cd39ab829bceda229c96254908e819662 Mon Sep 17 00:00:00 2001 From: sangrae Date: Fri, 25 Sep 2026 18:09:41 +0900 Subject: [PATCH 08/14] =?UTF-8?q?refactor:=20=EC=82=AC=EB=AC=BC=ED=95=A8?= =?UTF-8?q?=20=EC=8B=A0=EC=B2=AD=20=EC=A1=B0=ED=9A=8C=EC=97=90=EC=84=9C=20?= =?UTF-8?q?readOnly=20=ED=8A=B8=EB=9E=9C=EC=9E=AD=EC=85=98=20=EC=A0=9C?= =?UTF-8?q?=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 앞 조회 결과를 뒤 조회 입력으로 쓰는 순차 조회라 두 조회가 한 스냅샷일 이유가 없다. 여러 조회가 하나의 집계를 만드는 getSections·getSectionLockers와 다르다. 레포지토리 메서드는 SimpleJpaRepository의 클래스 레벨 readOnly 트랜잭션 안에서 각자 돌아 동작은 그대로다. --- .../event/domain/locker/service/impl/LockerServiceImpl.java | 1 - 1 file changed, 1 deletion(-) diff --git a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/service/impl/LockerServiceImpl.java b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/service/impl/LockerServiceImpl.java index d85e5fa9..2fe313c7 100644 --- a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/service/impl/LockerServiceImpl.java +++ b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/service/impl/LockerServiceImpl.java @@ -55,7 +55,6 @@ public List getSectionLockers(Long lockerPeriodId, Long sect } @Override - @Transactional(readOnly = true) public Optional getLockerByMemberId(Long lockerPeriodId, Long memberId) { return lockerRepository.findAppliedLockerId(lockerPeriodId, memberId) .flatMap(lockerRepository::findLockerById); From 679ab5d70c28c2b1bcb778ff905d5c094e6128c7 Mon Sep 17 00:00:00 2001 From: sangrae Date: Fri, 25 Sep 2026 18:13:12 +0900 Subject: [PATCH 09/14] =?UTF-8?q?docs:=20=EC=82=AC=EB=AC=BC=ED=95=A8=20?= =?UTF-8?q?=EC=A3=BC=EC=84=9D=EC=97=90=EC=84=9C=20=EC=84=A4=EA=B3=84=20?= =?UTF-8?q?=EA=B7=BC=EA=B1=B0=20=EB=8B=A8=EB=9D=BD=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR 본문에 있는 설계 판단 근거를 javadoc에 중복으로 적어둬서, 설계가 바뀌면 같이 어긋난다. 메서드가 무엇을 돌려주는지만 남긴다. --- .../api/app/event/locker/AppLockerController.java | 4 +--- .../domain/locker/domain/LockerAvailability.java | 5 +---- .../domain/locker/domain/LockerSectionSummary.java | 5 +---- .../domain/locker/repository/LockerRepository.java | 12 +----------- .../event/domain/locker/service/LockerService.java | 7 +------ 5 files changed, 5 insertions(+), 28 deletions(-) diff --git a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/event/locker/AppLockerController.java b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/event/locker/AppLockerController.java index 6a265060..737e9445 100644 --- a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/event/locker/AppLockerController.java +++ b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/event/locker/AppLockerController.java @@ -21,9 +21,7 @@ /** * 학생 앱의 사물함 구역·배치 조회 API. *

- * 구역과 사물함 조회 결과는 보는 사람과 무관하다. 내 사물함 표시는 그 결과에 회원의 신청 사물함을 - * 맞춰봐서 여기서 만든다. 조회 순서를 지켜야 한다. 미게시 회차·없는 구역 판정은 구역 조회가 하므로 - * 그쪽을 먼저 호출해야 404가 신청 조회보다 앞선다. + * 미게시 회차·없는 구역 판정은 구역 조회가 하므로 신청 조회보다 먼저 호출해야 404가 앞선다. */ @RestController @RequestMapping("/v1/app/lockers") diff --git a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/domain/LockerAvailability.java b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/domain/LockerAvailability.java index 5c767b2a..5dcaa0fa 100644 --- a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/domain/LockerAvailability.java +++ b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/domain/LockerAvailability.java @@ -1,10 +1,7 @@ package kr.ac.kookmin.stream.event.domain.locker.domain; /** - * 배치도에 그릴 사물함 한 건. 선택 가능 여부는 사물함 자체 상태와 해당 운영 회차의 신청 여부를 함께 본다. - *

- * 조회한 회원에 따라 달라지는 값은 담지 않는다. 내 사물함 표시처럼 보는 사람이 기준인 값은 이 결과에 - * {@code LockerService#getLockerByMemberId}로 읽은 사물함을 맞춰봐서 표현 계층에서 만든다. + * 배치도에 그릴 사물함 한 건. 조회한 회원에 따라 달라지는 값은 담지 않는다. */ public record LockerAvailability( Long lockerId, diff --git a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/domain/LockerSectionSummary.java b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/domain/LockerSectionSummary.java index dbcd4a79..13a32db5 100644 --- a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/domain/LockerSectionSummary.java +++ b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/domain/LockerSectionSummary.java @@ -4,10 +4,7 @@ import java.util.Set; /** - * 사물함 구역 목록 한 건. 전체·선택 가능 수와 표시 상태는 저장값이 아니라 조회 시점에 센다. - *

- * 조회한 회원에 따라 달라지는 값은 담지 않는다. 내 사물함이 있는 구역인지는 이 결과와 - * {@code LockerService#getLockerByMemberId}의 구역을 맞춰보면 나온다. + * 사물함 구역 목록 한 건. 전체·선택 가능 수는 조회 시점에 세고, 조회한 회원에 따라 달라지는 값은 담지 않는다. */ public record LockerSectionSummary( Long sectionId, diff --git a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/repository/LockerRepository.java b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/repository/LockerRepository.java index 7ca63b11..9aa40667 100644 --- a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/repository/LockerRepository.java +++ b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/repository/LockerRepository.java @@ -10,25 +10,18 @@ public interface LockerRepository { /** * 게시된 운영 회차가 있는지. 아직 공개하지 않은 회차는 학생에게 없는 것으로 보여야 한다. - *

- * 조회 API는 회차의 존재만 확인하면 되므로 회차 자체는 읽지 않는다. 신청 기간 판정처럼 회차 값이 - * 필요해지면 그때 조회 메서드를 추가한다. */ boolean existsPublishedPeriod(Long lockerPeriodId); boolean existsSection(Long sectionId); /** - * 전체 구역을 식별자 오름차순으로 조회한다. 화면이 구역 자리를 알고 채우므로 별도 표시 순서는 두지 않고, - * 같은 요청이 항상 같은 순서를 돌려주도록 식별자로만 정렬한다. + * 전체 구역을 식별자 오름차순으로 조회한다. */ List findAllSections(); /** * 삭제되지 않은 사물함 전체를 조회한다. 구역별 집계는 서비스가 이 목록을 묶어 센다. - *

- * 선택 가능 판정({@code Locker.isSelectable})을 SQL로 옮기지 않으려는 선택이다. 사물함은 물리적 수량이라 - * 규모가 유한하고 구역 목록은 어차피 전부 내려가므로 전부 읽어도 부담이 없다. */ List findAllLockers(); @@ -49,9 +42,6 @@ public interface LockerRepository { /** * 해당 운영 회차에서 회원이 신청한 사물함 식별자. 회차당 한 건만 신청할 수 있고 취소가 없어 최대 하나다. - *

- * 사물함 값이 필요하면 {@link #findLockerById(Long)}로 이어 읽는다. 신청과 사물함을 조인해 한 번에 - * 읽지 않는 이유는 이 포트의 조회를 전부 단일 테이블 조회로 두려는 것이다. */ Optional findAppliedLockerId(Long lockerPeriodId, Long memberId); } diff --git a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/service/LockerService.java b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/service/LockerService.java index f2e62d7b..6156cb1f 100644 --- a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/service/LockerService.java +++ b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/service/LockerService.java @@ -21,12 +21,7 @@ public interface LockerService { /** * 해당 운영 회차에서 회원이 신청한 사물함. 회차당 한 건만 신청할 수 있고 취소가 없어 최대 하나다. *

- * 조회하는 쪽이 자기 사물함을 보는지 남의 것을 보는지 가리지 않는다. 학생 앱은 요청한 회원의 - * 식별자를 넘겨 "내 사물함"을 표시하고, 운영진 화면처럼 다른 회원의 배정을 확인해야 하는 곳도 - * 같은 메서드를 쓴다. 노출 범위 판단은 호출하는 쪽의 몫이다. - *

- * 회차 게시 여부는 확인하지 않는다. 신청은 회차 공개 여부와 무관하게 이미 이뤄진 사실이고, 학생 앱 - * 조회 엔드포인트는 구역 조회에서 미게시 회차를 먼저 404로 거른 뒤 이 메서드를 쓴다. + * 회차 게시 여부와 노출 범위는 확인하지 않는다. 호출하는 쪽이 판단한다. */ Optional getLockerByMemberId(Long lockerPeriodId, Long memberId); } From aafcbac6a6890f8079baf640b3268c6a00cc12c4 Mon Sep 17 00:00:00 2001 From: sangrae Date: Fri, 25 Sep 2026 18:29:13 +0900 Subject: [PATCH 10/14] =?UTF-8?q?refactor:=20LockerSectionListParams?= =?UTF-8?q?=EB=A5=BC=20LockerPeriodParams=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 구역 목록과 구역 상세가 같은 파라미터 객체를 쓰는데 이름에 목록이라는 액션이 들어가 있었다. 담고 있는 값이 운영 회차 식별자 하나뿐이라 내용으로 이름을 붙인다. --- .../kookmin/stream/api/app/event/locker/AppLockerApi.java | 6 +++--- .../stream/api/app/event/locker/AppLockerController.java | 6 +++--- ...LockerSectionListParams.java => LockerPeriodParams.java} | 2 +- 3 files changed, 7 insertions(+), 7 deletions(-) rename api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/event/locker/request/{LockerSectionListParams.java => LockerPeriodParams.java} (88%) diff --git a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/event/locker/AppLockerApi.java b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/event/locker/AppLockerApi.java index 2c035019..5978ecb2 100644 --- a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/event/locker/AppLockerApi.java +++ b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/event/locker/AppLockerApi.java @@ -3,7 +3,7 @@ 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.event.locker.request.LockerSectionListParams; +import kr.ac.kookmin.stream.api.app.event.locker.request.LockerPeriodParams; import kr.ac.kookmin.stream.api.app.event.locker.response.LockerLayoutResponse; import kr.ac.kookmin.stream.api.app.event.locker.response.LockerSectionListResponse; import kr.ac.kookmin.stream.api.common.dto.ApiResponse; @@ -29,7 +29,7 @@ public interface AppLockerApi { @ApiErrorCode(type = LockerErrorCode.class, codes = {"LOCKER_PERIOD_NOT_FOUND"}) ApiResponse getSections( AppApiUser apiUser, - @ParameterObject LockerSectionListParams params + @ParameterObject LockerPeriodParams params ); /** 구역에 속한 사물함의 배치 정보와 선택 가능 여부. */ @@ -41,6 +41,6 @@ ApiResponse getSections( ApiResponse getSectionLockers( AppApiUser apiUser, Long sectionId, - @ParameterObject LockerSectionListParams params + @ParameterObject LockerPeriodParams params ); } diff --git a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/event/locker/AppLockerController.java b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/event/locker/AppLockerController.java index 737e9445..85b4119b 100644 --- a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/event/locker/AppLockerController.java +++ b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/event/locker/AppLockerController.java @@ -3,7 +3,7 @@ import jakarta.validation.Valid; import java.util.List; import kr.ac.kookmin.stream.api.app.AppApiUser; -import kr.ac.kookmin.stream.api.app.event.locker.request.LockerSectionListParams; +import kr.ac.kookmin.stream.api.app.event.locker.request.LockerPeriodParams; import kr.ac.kookmin.stream.api.app.event.locker.response.LockerLayoutResponse; import kr.ac.kookmin.stream.api.app.event.locker.response.LockerSectionListResponse; import kr.ac.kookmin.stream.api.common.dto.ApiResponse; @@ -34,7 +34,7 @@ public class AppLockerController implements AppLockerApi { @GetMapping("/sections") public ApiResponse getSections( AppApiUser apiUser, - @Valid @ModelAttribute LockerSectionListParams params + @Valid @ModelAttribute LockerPeriodParams params ) { Long lockerPeriodId = params.lockerPeriodId(); List sections = lockerService.getSections(lockerPeriodId); @@ -50,7 +50,7 @@ public ApiResponse getSections( public ApiResponse getSectionLockers( AppApiUser apiUser, @PathVariable Long sectionId, - @Valid @ModelAttribute LockerSectionListParams params + @Valid @ModelAttribute LockerPeriodParams params ) { Long lockerPeriodId = params.lockerPeriodId(); List lockers = lockerService.getSectionLockers(lockerPeriodId, sectionId); diff --git a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/event/locker/request/LockerSectionListParams.java b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/event/locker/request/LockerPeriodParams.java similarity index 88% rename from api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/event/locker/request/LockerSectionListParams.java rename to api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/event/locker/request/LockerPeriodParams.java index cf0af133..eeb9227f 100644 --- a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/event/locker/request/LockerSectionListParams.java +++ b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/event/locker/request/LockerPeriodParams.java @@ -5,7 +5,7 @@ /** * @param lockerPeriodId 조회할 사물함 운영 회차 식별자 */ -public record LockerSectionListParams( +public record LockerPeriodParams( @NotNull(message = "사물함 운영 회차를 입력해 주세요.") Long lockerPeriodId From eedcb8511c5186e0b17dc31914d069c410836eb2 Mon Sep 17 00:00:00 2001 From: sangrae Date: Mon, 28 Sep 2026 13:27:48 +0900 Subject: [PATCH 11/14] =?UTF-8?q?refactor:=20=EA=B5=AC=EC=97=AD=EB=B3=84?= =?UTF-8?q?=20=EC=82=AC=EB=AC=BC=ED=95=A8=20=EB=AC=B6=EC=9D=8C=EC=9D=84=20?= =?UTF-8?q?=EC=84=9C=EB=B9=84=EC=8A=A4=20=EA=B3=B5=EA=B0=9C=20=EB=A9=94?= =?UTF-8?q?=EC=84=9C=EB=93=9C=EB=A1=9C=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 구역 목록 조회 안에서만 만들던 구역별 사물함 묶음을 다른 곳에서도 쓸 수 있게 포트로 올린다. 쿼리 한 개짜리 단순 조회라 readOnly 트랜잭션은 붙이지 않는다. --- .../event/domain/locker/service/LockerService.java | 9 +++++++++ .../domain/locker/service/impl/LockerServiceImpl.java | 9 +++++++-- 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/service/LockerService.java b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/service/LockerService.java index 6156cb1f..3996acaf 100644 --- a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/service/LockerService.java +++ b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/service/LockerService.java @@ -1,6 +1,7 @@ package kr.ac.kookmin.stream.event.domain.locker.service; import java.util.List; +import java.util.Map; import java.util.Optional; import kr.ac.kookmin.stream.event.domain.locker.domain.Locker; import kr.ac.kookmin.stream.event.domain.locker.domain.LockerAvailability; @@ -18,6 +19,14 @@ public interface LockerService { */ List getSectionLockers(Long lockerPeriodId, Long sectionId); + /** + * 삭제되지 않은 사물함 전체를 구역별로 묶어 조회한다. + *

+ * 사물함이 한 건도 없는 구역은 키가 없다. 구역 목록을 만들 때처럼 빈 구역도 빠뜨리면 안 되는 쪽은 + * {@code getOrDefault}로 받아야 한다. + */ + Map> getLockerMapBySectionId(); + /** * 해당 운영 회차에서 회원이 신청한 사물함. 회차당 한 건만 신청할 수 있고 취소가 없어 최대 하나다. *

diff --git a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/service/impl/LockerServiceImpl.java b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/service/impl/LockerServiceImpl.java index 2fe313c7..c87fd41b 100644 --- a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/service/impl/LockerServiceImpl.java +++ b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/service/impl/LockerServiceImpl.java @@ -28,8 +28,7 @@ public List getSections(Long lockerPeriodId) { requirePublishedPeriod(lockerPeriodId); Set appliedLockerIds = lockerRepository.findAppliedLockerIds(lockerPeriodId); - Map> lockersBySection = lockerRepository.findAllLockers().stream() - .collect(Collectors.groupingBy(Locker::getSectionId)); + Map> lockersBySection = getLockerMapBySectionId(); return lockerRepository.findAllSections().stream() .map(section -> LockerSectionSummary.of( @@ -54,6 +53,12 @@ public List getSectionLockers(Long lockerPeriodId, Long sect .toList(); } + @Override + public Map> getLockerMapBySectionId() { + return lockerRepository.findAllLockers().stream() + .collect(Collectors.groupingBy(Locker::getSectionId)); + } + @Override public Optional getLockerByMemberId(Long lockerPeriodId, Long memberId) { return lockerRepository.findAppliedLockerId(lockerPeriodId, memberId) From 18a26172cf48fb5dc2dd07a0b64109b7d10c643b Mon Sep 17 00:00:00 2001 From: sangrae Date: Mon, 28 Sep 2026 13:38:30 +0900 Subject: [PATCH 12/14] =?UTF-8?q?refactor:=20LockerAvailability=EB=A5=BC?= =?UTF-8?q?=20=EC=97=86=EC=95=A0=EA=B3=A0=20=EC=9D=91=EB=8B=B5=20=EC=A1=B0?= =?UTF-8?q?=EB=A6=BD=EC=9D=84=20api=EB=A1=9C=20=EC=98=AE=EA=B9=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Locker와 신청 여부를 조합해 응답 필드를 만드는 중간 객체라 응답 DTO와 1:1이었다. 선택 가능 판정은 Locker.isSelectable()에 있으므로 api에서 그 메서드를 호출해 LockerResponse를 조립한다. 판정이 서비스 테스트에서 빠지는 대신 LockerTest로 규칙 자체를 덮는다. --- .../app/event/locker/AppLockerController.java | 10 +++-- .../locker/response/LockerLayoutResponse.java | 14 +++++-- .../event/locker/response/LockerResponse.java | 19 +++++---- .../locker/domain/LockerAvailability.java | 28 ------------- .../domain/locker/service/LockerService.java | 14 +++++-- .../service/impl/LockerServiceImpl.java | 13 +++--- .../domain/locker/domain/LockerTest.java | 41 +++++++++++++++++++ .../service/impl/LockerServiceImplTest.java | 31 ++++++++------ 8 files changed, 103 insertions(+), 67 deletions(-) delete mode 100644 core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/domain/LockerAvailability.java create mode 100644 core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/locker/domain/LockerTest.java diff --git a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/event/locker/AppLockerController.java b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/event/locker/AppLockerController.java index 85b4119b..3a9a7fd6 100644 --- a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/event/locker/AppLockerController.java +++ b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/event/locker/AppLockerController.java @@ -2,13 +2,13 @@ import jakarta.validation.Valid; import java.util.List; +import java.util.Set; import kr.ac.kookmin.stream.api.app.AppApiUser; import kr.ac.kookmin.stream.api.app.event.locker.request.LockerPeriodParams; import kr.ac.kookmin.stream.api.app.event.locker.response.LockerLayoutResponse; import kr.ac.kookmin.stream.api.app.event.locker.response.LockerSectionListResponse; import kr.ac.kookmin.stream.api.common.dto.ApiResponse; import kr.ac.kookmin.stream.event.domain.locker.domain.Locker; -import kr.ac.kookmin.stream.event.domain.locker.domain.LockerAvailability; import kr.ac.kookmin.stream.event.domain.locker.domain.LockerSectionSummary; import kr.ac.kookmin.stream.event.domain.locker.service.LockerService; import lombok.RequiredArgsConstructor; @@ -21,7 +21,8 @@ /** * 학생 앱의 사물함 구역·배치 조회 API. *

- * 미게시 회차·없는 구역 판정은 구역 조회가 하므로 신청 조회보다 먼저 호출해야 404가 앞선다. + * 선택 가능 여부와 내 사물함 표시는 조회 결과를 응답 DTO에서 맞춰봐서 만든다. 미게시 회차·없는 구역 + * 판정은 구역 조회가 하므로 신청 조회보다 먼저 호출해야 404가 앞선다. */ @RestController @RequestMapping("/v1/app/lockers") @@ -53,11 +54,12 @@ public ApiResponse getSectionLockers( @Valid @ModelAttribute LockerPeriodParams params ) { Long lockerPeriodId = params.lockerPeriodId(); - List lockers = lockerService.getSectionLockers(lockerPeriodId, sectionId); + List lockers = lockerService.getSectionLockers(lockerPeriodId, sectionId); + Set appliedLockerIds = lockerService.getAppliedLockerIds(lockerPeriodId); Long myLockerId = lockerService.getLockerByMemberId(lockerPeriodId, apiUser.userId()) .map(Locker::getId) .orElse(null); - return ApiResponse.success(LockerLayoutResponse.of(lockers, myLockerId)); + return ApiResponse.success(LockerLayoutResponse.of(lockers, appliedLockerIds, myLockerId)); } } diff --git a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/event/locker/response/LockerLayoutResponse.java b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/event/locker/response/LockerLayoutResponse.java index 8f119820..be9192c7 100644 --- a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/event/locker/response/LockerLayoutResponse.java +++ b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/event/locker/response/LockerLayoutResponse.java @@ -1,16 +1,22 @@ package kr.ac.kookmin.stream.api.app.event.locker.response; import java.util.List; -import kr.ac.kookmin.stream.event.domain.locker.domain.LockerAvailability; +import java.util.Set; +import kr.ac.kookmin.stream.event.domain.locker.domain.Locker; public record LockerLayoutResponse(List lockers) { /** - * @param myLockerId 조회한 회원이 신청한 사물함. 신청하지 않았으면 {@code null} + * @param appliedLockerIds 해당 운영 회차에 이미 신청된 사물함 식별자 + * @param myLockerId 조회한 회원이 신청한 사물함. 신청하지 않았으면 {@code null} */ - public static LockerLayoutResponse of(List lockers, Long myLockerId) { + public static LockerLayoutResponse of( + List lockers, + Set appliedLockerIds, + Long myLockerId + ) { return new LockerLayoutResponse(lockers.stream() - .map(locker -> LockerResponse.of(locker, myLockerId)) + .map(locker -> LockerResponse.of(locker, appliedLockerIds.contains(locker.getId()), myLockerId)) .toList()); } } diff --git a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/event/locker/response/LockerResponse.java b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/event/locker/response/LockerResponse.java index b88a0781..fb04cf58 100644 --- a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/event/locker/response/LockerResponse.java +++ b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/event/locker/response/LockerResponse.java @@ -1,7 +1,7 @@ package kr.ac.kookmin.stream.api.app.event.locker.response; import java.util.Objects; -import kr.ac.kookmin.stream.event.domain.locker.domain.LockerAvailability; +import kr.ac.kookmin.stream.event.domain.locker.domain.Locker; public record LockerResponse( Long lockerId, @@ -14,17 +14,18 @@ public record LockerResponse( ) { /** + * @param applied 해당 운영 회차에 이 사물함이 이미 신청되었는지 * @param myLockerId 조회한 회원이 신청한 사물함. 신청하지 않았으면 {@code null} */ - public static LockerResponse of(LockerAvailability locker, Long myLockerId) { + public static LockerResponse of(Locker locker, boolean applied, Long myLockerId) { return new LockerResponse( - locker.lockerId(), - locker.lockerLabel(), - locker.lockerNumber(), - locker.rowNo(), - locker.columnNo(), - locker.available(), - Objects.equals(locker.lockerId(), myLockerId) + locker.getId(), + locker.getLockerLabel(), + locker.getLockerNumber(), + locker.getRowNo(), + locker.getColumnNo(), + locker.isSelectable(applied), + Objects.equals(locker.getId(), myLockerId) ); } } diff --git a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/domain/LockerAvailability.java b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/domain/LockerAvailability.java deleted file mode 100644 index 5dcaa0fa..00000000 --- a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/domain/LockerAvailability.java +++ /dev/null @@ -1,28 +0,0 @@ -package kr.ac.kookmin.stream.event.domain.locker.domain; - -/** - * 배치도에 그릴 사물함 한 건. 조회한 회원에 따라 달라지는 값은 담지 않는다. - */ -public record LockerAvailability( - Long lockerId, - String lockerLabel, - int lockerNumber, - int rowNo, - int columnNo, - boolean available -) { - - /** - * @param applied 해당 운영 회차에 이 사물함이 이미 신청되었는지 - */ - public static LockerAvailability of(Locker locker, boolean applied) { - return new LockerAvailability( - locker.getId(), - locker.getLockerLabel(), - locker.getLockerNumber(), - locker.getRowNo(), - locker.getColumnNo(), - locker.isSelectable(applied) - ); - } -} diff --git a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/service/LockerService.java b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/service/LockerService.java index 3996acaf..c6730023 100644 --- a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/service/LockerService.java +++ b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/service/LockerService.java @@ -3,8 +3,8 @@ import java.util.List; import java.util.Map; import java.util.Optional; +import java.util.Set; import kr.ac.kookmin.stream.event.domain.locker.domain.Locker; -import kr.ac.kookmin.stream.event.domain.locker.domain.LockerAvailability; import kr.ac.kookmin.stream.event.domain.locker.domain.LockerSectionSummary; public interface LockerService { @@ -15,9 +15,17 @@ public interface LockerService { List getSections(Long lockerPeriodId); /** - * 구역에 속한 사물함의 배치 정보와 선택 가능 여부를 조회한다. + * 구역에 속한 사물함을 배치도 순서(행 → 열)로 조회한다. + *

+ * 선택 가능 여부는 담지 않는다. 판정은 {@link Locker#isSelectable(boolean)}이 갖고 있고, 신청 여부는 + * {@link #getAppliedLockerIds(Long)}로 함께 읽어 호출하는 쪽에서 맞춰본다. + */ + List getSectionLockers(Long lockerPeriodId, Long sectionId); + + /** + * 해당 운영 회차에 이미 신청된 사물함 식별자. */ - List getSectionLockers(Long lockerPeriodId, Long sectionId); + Set getAppliedLockerIds(Long lockerPeriodId); /** * 삭제되지 않은 사물함 전체를 구역별로 묶어 조회한다. diff --git a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/service/impl/LockerServiceImpl.java b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/service/impl/LockerServiceImpl.java index c87fd41b..c90bbab2 100644 --- a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/service/impl/LockerServiceImpl.java +++ b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/service/impl/LockerServiceImpl.java @@ -7,7 +7,6 @@ import java.util.stream.Collectors; import kr.ac.kookmin.stream.common.BusinessException; import kr.ac.kookmin.stream.event.domain.locker.domain.Locker; -import kr.ac.kookmin.stream.event.domain.locker.domain.LockerAvailability; import kr.ac.kookmin.stream.event.domain.locker.domain.LockerErrorCode; import kr.ac.kookmin.stream.event.domain.locker.domain.LockerSectionSummary; import kr.ac.kookmin.stream.event.domain.locker.repository.LockerRepository; @@ -41,16 +40,16 @@ public List getSections(Long lockerPeriodId) { } @Override - @Transactional(readOnly = true) - public List getSectionLockers(Long lockerPeriodId, Long sectionId) { + public List getSectionLockers(Long lockerPeriodId, Long sectionId) { requirePublishedPeriod(lockerPeriodId); requireSection(sectionId); - Set appliedLockerIds = lockerRepository.findAppliedLockerIds(lockerPeriodId); + return lockerRepository.findLockersBySectionId(sectionId); + } - return lockerRepository.findLockersBySectionId(sectionId).stream() - .map(locker -> LockerAvailability.of(locker, appliedLockerIds.contains(locker.getId()))) - .toList(); + @Override + public Set getAppliedLockerIds(Long lockerPeriodId) { + return lockerRepository.findAppliedLockerIds(lockerPeriodId); } @Override diff --git a/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/locker/domain/LockerTest.java b/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/locker/domain/LockerTest.java new file mode 100644 index 00000000..d922e3ae --- /dev/null +++ b/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/locker/domain/LockerTest.java @@ -0,0 +1,41 @@ +package kr.ac.kookmin.stream.event.domain.locker.domain; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** + * 선택 가능 판정은 구역 목록의 선택 가능 수와 구역 상세의 선택 가능 여부가 함께 쓰는 규칙이라 여기서 덮는다. + */ +class LockerTest { + + private static Locker locker(LockerStatus status) { + return Locker.of(1L, 10L, "A-1", 1, 1, 1, status); + } + + @Test + @DisplayName("사용 가능하고 신청되지 않았으면 선택할 수 있다") + void selectableWhenAvailableAndNotApplied() { + assertTrue(locker(LockerStatus.AVAILABLE).isSelectable(false)); + } + + @Test + @DisplayName("사용 가능해도 이미 신청됐으면 선택할 수 없다") + void notSelectableWhenApplied() { + assertFalse(locker(LockerStatus.AVAILABLE).isSelectable(true)); + } + + @Test + @DisplayName("사용 중지된 사물함은 신청되지 않았어도 선택할 수 없다") + void notSelectableWhenDisabled() { + assertFalse(locker(LockerStatus.DISABLED).isSelectable(false)); + } + + @Test + @DisplayName("사용 중지됐고 신청까지 됐으면 선택할 수 없다") + void notSelectableWhenDisabledAndApplied() { + assertFalse(locker(LockerStatus.DISABLED).isSelectable(true)); + } +} diff --git a/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/locker/service/impl/LockerServiceImplTest.java b/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/locker/service/impl/LockerServiceImplTest.java index 0e2a1df9..d1262c57 100644 --- a/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/locker/service/impl/LockerServiceImplTest.java +++ b/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/locker/service/impl/LockerServiceImplTest.java @@ -1,7 +1,6 @@ package kr.ac.kookmin.stream.event.domain.locker.service.impl; import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -11,7 +10,6 @@ import java.util.stream.Stream; import kr.ac.kookmin.stream.common.BusinessException; import kr.ac.kookmin.stream.event.domain.locker.domain.Locker; -import kr.ac.kookmin.stream.event.domain.locker.domain.LockerAvailability; import kr.ac.kookmin.stream.event.domain.locker.domain.LockerErrorCode; import kr.ac.kookmin.stream.event.domain.locker.domain.LockerSection; import kr.ac.kookmin.stream.event.domain.locker.domain.LockerSectionSummary; @@ -25,7 +23,8 @@ /** * 구역·사물함·신청을 각각 조회해 서비스가 구역별로 묶어 센다. 그 집계가 어긋나지 않는지 확인한다. *

- * 내 사물함 표시는 표현 계층이 {@code getLockerByMemberId} 결과를 맞춰봐서 만들므로 여기서는 그 조회만 본다. + * 선택 가능 여부와 내 사물함 표시는 표현 계층이 조회 결과를 맞춰봐서 만든다. 판정 규칙 자체는 + * {@code LockerTest}가 덮으므로 여기서는 조회가 무엇을 돌려주는지만 본다. */ class LockerServiceImplTest { @@ -101,21 +100,29 @@ void unpublishedPeriod() { class GetSectionLockers { @Test - @DisplayName("사용 중지됐거나 이미 신청된 사물함은 선택할 수 없다") - void availability() { + @DisplayName("구역의 사물함을 레포지토리가 준 순서대로 돌려준다") + void returnsSectionLockers() { + // 선택 가능 판정은 담지 않는다. 판정 자체는 LockerTest가 덮는다 FakeLockerRepository repository = new FakeLockerRepository() .withSectionLockers( usable(11L, 1L), locker(12L, 1L, LockerStatus.DISABLED), - usable(13L, 1L)) - .withAppliedLockerIds(13L); + usable(13L, 1L)); + + List lockers = new LockerServiceImpl(repository).getSectionLockers(PERIOD_ID, 1L); - List lockers = - new LockerServiceImpl(repository).getSectionLockers(PERIOD_ID, 1L); + assertEquals(List.of(11L, 12L, 13L), lockers.stream().map(Locker::getId).toList()); + } - assertTrue(lockers.get(0).available()); // 사용 가능 + 미신청 - assertFalse(lockers.get(1).available()); // 사용 중지 - assertFalse(lockers.get(2).available()); // 이미 신청됨 + @Test + @DisplayName("게시되지 않은 회차는 찾을 수 없다") + void unpublishedPeriod() { + FakeLockerRepository repository = new FakeLockerRepository().withUnpublishedPeriod(); + + BusinessException e = assertThrows(BusinessException.class, + () -> new LockerServiceImpl(repository).getSectionLockers(PERIOD_ID, 1L)); + + assertEquals(LockerErrorCode.LOCKER_PERIOD_NOT_FOUND, e.getErrorCode()); } @Test From a2645d6533515918d1162f2852d805d661880c68 Mon Sep 17 00:00:00 2001 From: sangrae Date: Mon, 28 Sep 2026 16:32:59 +0900 Subject: [PATCH 13/14] =?UTF-8?q?refactor:=20=EA=B5=AC=EC=97=AD=20?= =?UTF-8?q?=EC=9A=94=EC=95=BD=EC=9D=98=20=EC=A7=91=EA=B3=84=EB=A5=BC=20?= =?UTF-8?q?=EC=84=9C=EB=B9=84=EC=8A=A4=EB=A1=9C=20=EC=98=AE=EA=B9=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 읽기 모델의 정적 팩토리가 사물함을 받아 직접 세고 있었다. 세는 일은 서비스가 맡고 읽기 모델은 결과만 담는다. --- .../locker/domain/LockerSectionSummary.java | 28 +----------------- .../service/impl/LockerServiceImpl.java | 29 ++++++++++++++++++- 2 files changed, 29 insertions(+), 28 deletions(-) diff --git a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/domain/LockerSectionSummary.java b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/domain/LockerSectionSummary.java index 13a32db5..ddc68180 100644 --- a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/domain/LockerSectionSummary.java +++ b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/domain/LockerSectionSummary.java @@ -1,10 +1,7 @@ package kr.ac.kookmin.stream.event.domain.locker.domain; -import java.util.List; -import java.util.Set; - /** - * 사물함 구역 목록 한 건. 전체·선택 가능 수는 조회 시점에 세고, 조회한 회원에 따라 달라지는 값은 담지 않는다. + * 사물함 구역 목록 한 건. 전체·선택 가능 수는 저장값이 아니라 조회 시점에 센다. */ public record LockerSectionSummary( Long sectionId, @@ -13,27 +10,4 @@ public record LockerSectionSummary( int totalCount, SectionAvailabilityStatus availabilityStatus ) { - - /** - * @param lockers 구역에 속한 사물함. 사물함이 없는 구역이면 빈 목록이다 - * @param appliedLockerIds 해당 운영 회차에 이미 신청된 사물함 식별자 - */ - public static LockerSectionSummary of( - LockerSection section, - List lockers, - Set appliedLockerIds - ) { - int totalCount = lockers.size(); - int availableCount = (int) lockers.stream() - .filter(locker -> locker.isSelectable(appliedLockerIds.contains(locker.getId()))) - .count(); - - return new LockerSectionSummary( - section.getId(), - section.getLabel(), - availableCount, - totalCount, - SectionAvailabilityStatus.from(availableCount, totalCount) - ); - } } diff --git a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/service/impl/LockerServiceImpl.java b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/service/impl/LockerServiceImpl.java index c90bbab2..1f6d356d 100644 --- a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/service/impl/LockerServiceImpl.java +++ b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/service/impl/LockerServiceImpl.java @@ -8,7 +8,9 @@ import kr.ac.kookmin.stream.common.BusinessException; import kr.ac.kookmin.stream.event.domain.locker.domain.Locker; import kr.ac.kookmin.stream.event.domain.locker.domain.LockerErrorCode; +import kr.ac.kookmin.stream.event.domain.locker.domain.LockerSection; import kr.ac.kookmin.stream.event.domain.locker.domain.LockerSectionSummary; +import kr.ac.kookmin.stream.event.domain.locker.domain.SectionAvailabilityStatus; import kr.ac.kookmin.stream.event.domain.locker.repository.LockerRepository; import kr.ac.kookmin.stream.event.domain.locker.service.LockerService; import lombok.RequiredArgsConstructor; @@ -30,7 +32,7 @@ public List getSections(Long lockerPeriodId) { Map> lockersBySection = getLockerMapBySectionId(); return lockerRepository.findAllSections().stream() - .map(section -> LockerSectionSummary.of( + .map(section -> summarize( section, // 사물함이 한 건도 없는 구역은 묶음에 키가 없다. 빈 목록으로 채워 목록에서 빠지지 않게 한다 lockersBySection.getOrDefault(section.getId(), List.of()), @@ -64,6 +66,31 @@ public Optional getLockerByMemberId(Long lockerPeriodId, Long memberId) .flatMap(lockerRepository::findLockerById); } + /** + * 구역 하나의 전체·선택 가능 수를 센다. + * + * @param lockers 구역에 속한 사물함. 사물함이 없는 구역이면 빈 목록이다 + * @param appliedLockerIds 해당 운영 회차에 이미 신청된 사물함 식별자 + */ + private LockerSectionSummary summarize( + LockerSection section, + List lockers, + Set appliedLockerIds + ) { + int totalCount = lockers.size(); + int availableCount = (int) lockers.stream() + .filter(locker -> locker.isSelectable(appliedLockerIds.contains(locker.getId()))) + .count(); + + return new LockerSectionSummary( + section.getId(), + section.getLabel(), + availableCount, + totalCount, + SectionAvailabilityStatus.from(availableCount, totalCount) + ); + } + /** 아직 게시하지 않은 회차는 학생에게 없는 것으로 보여야 하므로 두 조회의 입구에서 같은 기준으로 거른다. */ private void requirePublishedPeriod(Long lockerPeriodId) { if (!lockerRepository.existsPublishedPeriod(lockerPeriodId)) { From f11561cc6f83df842a2cb9952a31c8e1b1d6e5c0 Mon Sep 17 00:00:00 2001 From: sangrae Date: Tue, 29 Sep 2026 12:17:26 +0900 Subject: [PATCH 14/14] =?UTF-8?q?refactor:=20=EC=82=AC=EB=AC=BC=ED=95=A8?= =?UTF-8?q?=20=EC=9D=91=EB=8B=B5=20DTO=20=EC=9D=B4=EB=A6=84=EC=9D=84=20?= =?UTF-8?q?=EB=AA=A9=EB=A1=9D=C2=B7=EC=83=81=EC=84=B8=20=EA=B4=80=EB=A1=80?= =?UTF-8?q?=EC=97=90=20=EB=A7=9E=EC=B6=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../kookmin/stream/api/app/event/locker/AppLockerApi.java | 4 ++-- .../stream/api/app/event/locker/AppLockerController.java | 6 +++--- ...LayoutResponse.java => LockerSectionDetailResponse.java} | 6 +++--- ...tionResponse.java => LockerSectionListItemResponse.java} | 6 +++--- .../event/locker/response/LockerSectionListResponse.java | 4 ++-- 5 files changed, 13 insertions(+), 13 deletions(-) rename api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/event/locker/response/{LockerLayoutResponse.java => LockerSectionDetailResponse.java} (76%) rename api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/event/locker/response/{LockerSectionResponse.java => LockerSectionListItemResponse.java} (80%) diff --git a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/event/locker/AppLockerApi.java b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/event/locker/AppLockerApi.java index 5978ecb2..20811a77 100644 --- a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/event/locker/AppLockerApi.java +++ b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/event/locker/AppLockerApi.java @@ -4,7 +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.event.locker.request.LockerPeriodParams; -import kr.ac.kookmin.stream.api.app.event.locker.response.LockerLayoutResponse; +import kr.ac.kookmin.stream.api.app.event.locker.response.LockerSectionDetailResponse; import kr.ac.kookmin.stream.api.app.event.locker.response.LockerSectionListResponse; import kr.ac.kookmin.stream.api.common.dto.ApiResponse; import kr.ac.kookmin.stream.api.common.openapi.ApiErrorCode; @@ -38,7 +38,7 @@ ApiResponse getSections( + "사물함이 사용 가능한 상태이고 해당 회차에 신청되지 않은 경우에만 선택할 수 있다.") @ApiErrorCode(type = CommonErrorCode.class, codes = {"INVALID_INPUT"}) @ApiErrorCode(type = LockerErrorCode.class, codes = {"LOCKER_PERIOD_NOT_FOUND", "LOCKER_SECTION_NOT_FOUND"}) - ApiResponse getSectionLockers( + ApiResponse getSectionLockers( AppApiUser apiUser, Long sectionId, @ParameterObject LockerPeriodParams params diff --git a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/event/locker/AppLockerController.java b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/event/locker/AppLockerController.java index 3a9a7fd6..4f662ec7 100644 --- a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/event/locker/AppLockerController.java +++ b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/event/locker/AppLockerController.java @@ -5,7 +5,7 @@ import java.util.Set; import kr.ac.kookmin.stream.api.app.AppApiUser; import kr.ac.kookmin.stream.api.app.event.locker.request.LockerPeriodParams; -import kr.ac.kookmin.stream.api.app.event.locker.response.LockerLayoutResponse; +import kr.ac.kookmin.stream.api.app.event.locker.response.LockerSectionDetailResponse; import kr.ac.kookmin.stream.api.app.event.locker.response.LockerSectionListResponse; import kr.ac.kookmin.stream.api.common.dto.ApiResponse; import kr.ac.kookmin.stream.event.domain.locker.domain.Locker; @@ -48,7 +48,7 @@ public ApiResponse getSections( @Override @GetMapping("/sections/{sectionId}") - public ApiResponse getSectionLockers( + public ApiResponse getSectionLockers( AppApiUser apiUser, @PathVariable Long sectionId, @Valid @ModelAttribute LockerPeriodParams params @@ -60,6 +60,6 @@ public ApiResponse getSectionLockers( .map(Locker::getId) .orElse(null); - return ApiResponse.success(LockerLayoutResponse.of(lockers, appliedLockerIds, myLockerId)); + return ApiResponse.success(LockerSectionDetailResponse.of(lockers, appliedLockerIds, myLockerId)); } } diff --git a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/event/locker/response/LockerLayoutResponse.java b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/event/locker/response/LockerSectionDetailResponse.java similarity index 76% rename from api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/event/locker/response/LockerLayoutResponse.java rename to api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/event/locker/response/LockerSectionDetailResponse.java index be9192c7..4880c6a2 100644 --- a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/event/locker/response/LockerLayoutResponse.java +++ b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/event/locker/response/LockerSectionDetailResponse.java @@ -4,18 +4,18 @@ import java.util.Set; import kr.ac.kookmin.stream.event.domain.locker.domain.Locker; -public record LockerLayoutResponse(List lockers) { +public record LockerSectionDetailResponse(List lockers) { /** * @param appliedLockerIds 해당 운영 회차에 이미 신청된 사물함 식별자 * @param myLockerId 조회한 회원이 신청한 사물함. 신청하지 않았으면 {@code null} */ - public static LockerLayoutResponse of( + public static LockerSectionDetailResponse of( List lockers, Set appliedLockerIds, Long myLockerId ) { - return new LockerLayoutResponse(lockers.stream() + return new LockerSectionDetailResponse(lockers.stream() .map(locker -> LockerResponse.of(locker, appliedLockerIds.contains(locker.getId()), myLockerId)) .toList()); } diff --git a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/event/locker/response/LockerSectionResponse.java b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/event/locker/response/LockerSectionListItemResponse.java similarity index 80% rename from api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/event/locker/response/LockerSectionResponse.java rename to api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/event/locker/response/LockerSectionListItemResponse.java index 422922ce..37768926 100644 --- a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/event/locker/response/LockerSectionResponse.java +++ b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/event/locker/response/LockerSectionListItemResponse.java @@ -4,7 +4,7 @@ import kr.ac.kookmin.stream.event.domain.locker.domain.LockerSectionSummary; import kr.ac.kookmin.stream.event.domain.locker.domain.SectionAvailabilityStatus; -public record LockerSectionResponse( +public record LockerSectionListItemResponse( Long sectionId, String section, int availableCount, @@ -16,8 +16,8 @@ public record LockerSectionResponse( /** * @param mySectionId 조회한 회원이 신청한 사물함이 속한 구역. 신청하지 않았으면 {@code null} */ - public static LockerSectionResponse of(LockerSectionSummary summary, Long mySectionId) { - return new LockerSectionResponse( + public static LockerSectionListItemResponse of(LockerSectionSummary summary, Long mySectionId) { + return new LockerSectionListItemResponse( summary.sectionId(), summary.label(), summary.availableCount(), diff --git a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/event/locker/response/LockerSectionListResponse.java b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/event/locker/response/LockerSectionListResponse.java index bf20588e..33e02a80 100644 --- a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/event/locker/response/LockerSectionListResponse.java +++ b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/event/locker/response/LockerSectionListResponse.java @@ -3,14 +3,14 @@ import java.util.List; import kr.ac.kookmin.stream.event.domain.locker.domain.LockerSectionSummary; -public record LockerSectionListResponse(List sections) { +public record LockerSectionListResponse(List sections) { /** * @param mySectionId 조회한 회원이 신청한 사물함이 속한 구역. 신청하지 않았으면 {@code null} */ public static LockerSectionListResponse of(List sections, Long mySectionId) { return new LockerSectionListResponse(sections.stream() - .map(summary -> LockerSectionResponse.of(summary, mySectionId)) + .map(summary -> LockerSectionListItemResponse.of(summary, mySectionId)) .toList()); } }