From 189162ea92a6540463a56908f46eb0e7e7b1a102 Mon Sep 17 00:00:00 2001
From: Sumin Hwang <163857590+tnals0924@users.noreply.github.com>
Date: Tue, 6 Oct 2026 13:25:41 +0900
Subject: [PATCH 1/7] =?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=EB=B0=B0=EC=B9=98=20=ED=85=8C=EC=9D=B4?=
=?UTF-8?q?=EB=B8=94=20=EC=B6=94=EA=B0=80?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../locker/domain/LockerSectionLayout.java | 30 ++++++++++
.../event/LockerSectionLayoutJpaEntity.java | 60 +++++++++++++++++++
.../LockerSectionLayoutJpaRepository.java | 9 +++
...5__create_locker_section_layouts_table.sql | 15 +++++
4 files changed, 114 insertions(+)
create mode 100644 core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/domain/LockerSectionLayout.java
create mode 100644 infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/LockerSectionLayoutJpaEntity.java
create mode 100644 infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/LockerSectionLayoutJpaRepository.java
create mode 100644 infrastructure/db/src/main/resources/db/migration/V15__create_locker_section_layouts_table.sql
diff --git a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/domain/LockerSectionLayout.java b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/domain/LockerSectionLayout.java
new file mode 100644
index 00000000..83632b71
--- /dev/null
+++ b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/domain/LockerSectionLayout.java
@@ -0,0 +1,30 @@
+package kr.ac.kookmin.stream.event.domain.locker.domain;
+
+import lombok.AccessLevel;
+import lombok.AllArgsConstructor;
+import lombok.EqualsAndHashCode;
+import lombok.Getter;
+
+/**
+ * 구역 상세 화면의 칸 배치 구조와 구역 사진. 구역마다 하나다.
+ *
+ * 배치 구조에는 칸 위치와 창문·벽면 같은 고정 구조만 담는다. 선택 가능 여부처럼 신청할 때마다 바뀌는 칸 상태는
+ * 조회 시점에 사물함에서 계산하고, 둘은 사물함 번호({@link Locker#getLockerNumber()})로 잇는다.
+ */
+@Getter
+@EqualsAndHashCode
+@AllArgsConstructor(access = AccessLevel.PRIVATE)
+public class LockerSectionLayout {
+
+ private Long id;
+ private Long sectionId;
+ /** root 블록 트리 JSON 원문. 서버는 해석하지 않고 그대로 내려준다. */
+ private String layout;
+ /** layout 형식 버전. 블록 종류·필드가 바뀌면 올려서 옛 앱이 모르는 형식을 알아채게 한다. */
+ private int version;
+ private Long photoFileId;
+
+ public static LockerSectionLayout of(Long id, Long sectionId, String layout, int version, Long photoFileId) {
+ return new LockerSectionLayout(id, sectionId, layout, version, photoFileId);
+ }
+}
diff --git a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/LockerSectionLayoutJpaEntity.java b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/LockerSectionLayoutJpaEntity.java
new file mode 100644
index 00000000..48afa0ae
--- /dev/null
+++ b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/LockerSectionLayoutJpaEntity.java
@@ -0,0 +1,60 @@
+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.LockerSectionLayout;
+import lombok.AccessLevel;
+import lombok.NoArgsConstructor;
+import org.hibernate.annotations.JdbcTypeCode;
+import org.hibernate.type.SqlTypes;
+
+@Entity
+@Table(name = "locker_section_layouts")
+@NoArgsConstructor(access = AccessLevel.PROTECTED)
+public class LockerSectionLayoutJpaEntity extends BaseTimeEntity {
+
+ @Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ @Column(name = "locker_section_layout_id")
+ private Long id;
+
+ @Column(name = "section_id", nullable = false)
+ private Long sectionId;
+
+ /** JSON 원문을 문자열 그대로 읽고 쓴다. 블록 구조는 앱이 해석한다. */
+ @JdbcTypeCode(SqlTypes.JSON)
+ @Column(nullable = false, columnDefinition = "json")
+ private String layout;
+
+ /**
+ * layout 형식 버전. 낙관적 락용 {@code @Version}이 아니다.
+ * 컬럼이 SMALLINT라 스키마 검증이 INTEGER를 기대하지 않도록 JDBC 타입을 지정한다.
+ */
+ @JdbcTypeCode(SqlTypes.SMALLINT)
+ @Column(nullable = false)
+ private int version;
+
+ @Column(name = "photo_file_id", nullable = false)
+ private Long photoFileId;
+
+ private LockerSectionLayoutJpaEntity(LockerSectionLayout sectionLayout) {
+ this.id = sectionLayout.getId();
+ this.sectionId = sectionLayout.getSectionId();
+ this.layout = sectionLayout.getLayout();
+ this.version = sectionLayout.getVersion();
+ this.photoFileId = sectionLayout.getPhotoFileId();
+ }
+
+ public static LockerSectionLayoutJpaEntity from(LockerSectionLayout sectionLayout) {
+ return new LockerSectionLayoutJpaEntity(sectionLayout);
+ }
+
+ public LockerSectionLayout toDomain() {
+ return LockerSectionLayout.of(id, sectionId, layout, version, photoFileId);
+ }
+}
diff --git a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/LockerSectionLayoutJpaRepository.java b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/LockerSectionLayoutJpaRepository.java
new file mode 100644
index 00000000..99124365
--- /dev/null
+++ b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/LockerSectionLayoutJpaRepository.java
@@ -0,0 +1,9 @@
+package kr.ac.kookmin.stream.db.event;
+
+import java.util.Optional;
+import org.springframework.data.jpa.repository.JpaRepository;
+
+public interface LockerSectionLayoutJpaRepository extends JpaRepository {
+
+ Optional findBySectionId(Long sectionId);
+}
diff --git a/infrastructure/db/src/main/resources/db/migration/V15__create_locker_section_layouts_table.sql b/infrastructure/db/src/main/resources/db/migration/V15__create_locker_section_layouts_table.sql
new file mode 100644
index 00000000..ec92dcc7
--- /dev/null
+++ b/infrastructure/db/src/main/resources/db/migration/V15__create_locker_section_layouts_table.sql
@@ -0,0 +1,15 @@
+-- 구역 상세 화면의 칸 배치 구조(layout)와 구역 사진을 구역별로 저장한다.
+-- 서버는 layout 내용을 해석하지 않고 그대로 내려준다. 칸 상태(선택 가능·내 사물함)는 layout에 넣지 않고 조회 시점에 계산한다.
+-- 관리자 등록 API가 아직 없어 행은 DB에 직접 넣는다.
+CREATE TABLE locker_section_layouts (
+ locker_section_layout_id BIGINT AUTO_INCREMENT PRIMARY KEY,
+ section_id BIGINT NOT NULL, -- locker_sections.locker_section_id
+ layout JSON NOT NULL, -- root 블록 트리. {"type": "column", "children": [...]}
+ version SMALLINT NOT NULL, -- layout 형식 버전. 블록 종류·필드가 바뀌면 올려 옛 앱이 모르는 형식을 알아채게 한다
+ photo_file_id BIGINT NOT NULL, -- 구역 실제 사진. files.file_id. 공개 URL은 웹 계층에서 만든다
+ created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
+);
+
+-- 구역마다 최신 형식 한 벌만 둔다. 구역 상세 조회(WHERE section_id = ?)도 이 인덱스를 탄다.
+CREATE UNIQUE INDEX uk_locker_section_layouts_section_id ON locker_section_layouts (section_id);
From 99ffb83bc5ba6e498963916497fc541e67ce3fc2 Mon Sep 17 00:00:00 2001
From: Sumin Hwang <163857590+tnals0924@users.noreply.github.com>
Date: Tue, 6 Oct 2026 13:27:07 +0900
Subject: [PATCH 2/7] =?UTF-8?q?feat:=20=EA=B5=AC=EC=97=AD=20=EC=83=81?=
=?UTF-8?q?=EC=84=B8=20=EC=A1=B0=ED=9A=8C=EA=B0=80=20=EA=B5=AC=EC=97=AD=20?=
=?UTF-8?q?=EC=A0=95=EB=B3=B4=EC=99=80=20=EB=B0=B0=EC=B9=98=EB=A5=BC=20?=
=?UTF-8?q?=ED=95=A8=EA=BB=98=20=EB=B0=98=ED=99=98?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../app/event/locker/AppLockerController.java | 2 +-
.../locker/domain/LockerSectionDetail.java | 16 ++++++++++++++
.../locker/repository/LockerRepository.java | 10 +++++++--
.../domain/locker/service/LockerService.java | 6 +++--
.../service/impl/LockerServiceImpl.java | 19 ++++++++--------
.../LockerApplicationServiceImplTest.java | 10 +++++++--
.../service/impl/LockerServiceImplTest.java | 22 ++++++++++++-------
.../stream/db/event/LockerRepositoryImpl.java | 19 +++++++++++-----
8 files changed, 75 insertions(+), 29 deletions(-)
create mode 100644 core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/domain/LockerSectionDetail.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 3cd79dae..67e66a7a 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
@@ -63,7 +63,7 @@ public ApiResponse getSectionLockers(
@Valid @ModelAttribute LockerPeriodParams params
) {
Long lockerPeriodId = params.lockerPeriodId();
- List lockers = lockerService.getSectionLockers(lockerPeriodId, sectionId);
+ List lockers = lockerService.getSectionDetail(lockerPeriodId, sectionId).lockers();
Set appliedLockerIds = lockerService.getAppliedLockerIds(lockerPeriodId);
Long myLockerId = lockerService.getLockerByMemberId(lockerPeriodId, apiUser.userId())
.map(Locker::getId)
diff --git a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/domain/LockerSectionDetail.java b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/domain/LockerSectionDetail.java
new file mode 100644
index 00000000..08c4d151
--- /dev/null
+++ b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/domain/LockerSectionDetail.java
@@ -0,0 +1,16 @@
+package kr.ac.kookmin.stream.event.domain.locker.domain;
+
+import java.util.List;
+
+/**
+ * 사물함 구역 상세 한 건. 칸 선택 화면이 구역 정보·배치 구조·사물함을 한 번에 받는다.
+ *
+ * @param layout 배치 구조와 사진. 아직 등록하지 않은 구역이면 {@code null}
+ * @param lockers 구역에 속한 사물함. 선택 가능 여부는 담지 않는다
+ */
+public record LockerSectionDetail(
+ LockerSection section,
+ LockerSectionLayout layout,
+ List lockers
+) {
+}
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 60299c67..928f5e48 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
@@ -6,6 +6,7 @@
import kr.ac.kookmin.stream.event.domain.locker.domain.Locker;
import kr.ac.kookmin.stream.event.domain.locker.domain.LockerPeriod;
import kr.ac.kookmin.stream.event.domain.locker.domain.LockerSection;
+import kr.ac.kookmin.stream.event.domain.locker.domain.LockerSectionLayout;
/**
* 사물함 운영 회차·구역·사물함 저장소. 사물함 신청은 {@link LockerApplicationRepository}가 맡는다.
@@ -17,8 +18,6 @@ public interface LockerRepository {
*/
boolean existsPublishedPeriod(Long lockerPeriodId);
- boolean existsSection(Long sectionId);
-
/**
* 게시된 운영 회차 한 건. 아직 공개하지 않은 회차는 없는 것으로 본다.
*/
@@ -34,6 +33,13 @@ public interface LockerRepository {
*/
List findAllSections();
+ Optional findSectionById(Long sectionId);
+
+ /**
+ * 구역의 배치 구조와 사진. 아직 등록하지 않은 구역이면 비어 있다.
+ */
+ Optional findLayoutBySectionId(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 c6730023..6ca368a3 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
@@ -5,6 +5,7 @@
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.LockerSectionDetail;
import kr.ac.kookmin.stream.event.domain.locker.domain.LockerSectionSummary;
public interface LockerService {
@@ -15,12 +16,13 @@ public interface LockerService {
List getSections(Long lockerPeriodId);
/**
- * 구역에 속한 사물함을 배치도 순서(행 → 열)로 조회한다.
+ * 구역 정보와 배치 구조·사진, 구역에 속한 사물함을 함께 조회한다. 배치 구조를 아직 등록하지 않은 구역이면
+ * {@link LockerSectionDetail#layout()}이 {@code null}이다.
*
* 선택 가능 여부는 담지 않는다. 판정은 {@link Locker#isSelectable(boolean)}이 갖고 있고, 신청 여부는
* {@link #getAppliedLockerIds(Long)}로 함께 읽어 호출하는 쪽에서 맞춰본다.
*/
- List getSectionLockers(Long lockerPeriodId, Long sectionId);
+ LockerSectionDetail getSectionDetail(Long lockerPeriodId, Long sectionId);
/**
* 해당 운영 회차에 이미 신청된 사물함 식별자.
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 e046de99..f4d02f9f 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
@@ -9,6 +9,7 @@
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.LockerSectionDetail;
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.LockerApplicationRepository;
@@ -44,11 +45,17 @@ public List getSections(Long lockerPeriodId) {
}
@Override
- public List getSectionLockers(Long lockerPeriodId, Long sectionId) {
+ @Transactional(readOnly = true)
+ public LockerSectionDetail getSectionDetail(Long lockerPeriodId, Long sectionId) {
requirePublishedPeriod(lockerPeriodId);
- requireSection(sectionId);
+ LockerSection section = lockerRepository.findSectionById(sectionId)
+ .orElseThrow(() -> new BusinessException(LockerErrorCode.LOCKER_SECTION_NOT_FOUND));
- return lockerRepository.findLockersBySectionId(sectionId);
+ return new LockerSectionDetail(
+ section,
+ lockerRepository.findLayoutBySectionId(sectionId).orElse(null),
+ lockerRepository.findLockersBySectionId(sectionId)
+ );
}
@Override
@@ -99,10 +106,4 @@ private void requirePublishedPeriod(Long 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/service/impl/LockerApplicationServiceImplTest.java b/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/locker/service/impl/LockerApplicationServiceImplTest.java
index c33098fa..9a0cd7d9 100644
--- a/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/locker/service/impl/LockerApplicationServiceImplTest.java
+++ b/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/locker/service/impl/LockerApplicationServiceImplTest.java
@@ -23,6 +23,7 @@
import kr.ac.kookmin.stream.event.domain.locker.domain.LockerErrorCode;
import kr.ac.kookmin.stream.event.domain.locker.domain.LockerPeriod;
import kr.ac.kookmin.stream.event.domain.locker.domain.LockerSection;
+import kr.ac.kookmin.stream.event.domain.locker.domain.LockerSectionLayout;
import kr.ac.kookmin.stream.event.domain.locker.domain.LockerStatus;
import kr.ac.kookmin.stream.event.domain.locker.repository.LockerApplicationRepository;
import kr.ac.kookmin.stream.event.domain.locker.repository.LockerRepository;
@@ -359,12 +360,17 @@ public boolean existsPublishedPeriod(Long lockerPeriodId) {
}
@Override
- public boolean existsSection(Long sectionId) {
+ public List findAllSections() {
throw new UnsupportedOperationException();
}
@Override
- public List findAllSections() {
+ public Optional findSectionById(Long sectionId) {
+ throw new UnsupportedOperationException();
+ }
+
+ @Override
+ public Optional findLayoutBySectionId(Long sectionId) {
throw new UnsupportedOperationException();
}
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 48f8b396..bc7c28f3 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
@@ -15,6 +15,7 @@
import kr.ac.kookmin.stream.event.domain.locker.domain.LockerErrorCode;
import kr.ac.kookmin.stream.event.domain.locker.domain.LockerPeriod;
import kr.ac.kookmin.stream.event.domain.locker.domain.LockerSection;
+import kr.ac.kookmin.stream.event.domain.locker.domain.LockerSectionLayout;
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;
@@ -105,7 +106,7 @@ void unpublishedPeriod() {
@Nested
@DisplayName("구역 상세")
- class GetSectionLockers {
+ class GetSectionDetail {
@Test
@DisplayName("구역의 사물함을 레포지토리가 준 순서대로 돌려준다")
@@ -117,7 +118,7 @@ void returnsSectionLockers() {
locker(12L, 1L, LockerStatus.DISABLED),
usable(13L, 1L));
- List lockers = service(repository).getSectionLockers(PERIOD_ID, 1L);
+ List lockers = service(repository).getSectionDetail(PERIOD_ID, 1L).lockers();
assertEquals(List.of(11L, 12L, 13L), lockers.stream().map(Locker::getId).toList());
}
@@ -128,7 +129,7 @@ void unpublishedPeriod() {
FakeLockerRepository repository = new FakeLockerRepository().withUnpublishedPeriod();
BusinessException e = assertThrows(BusinessException.class,
- () -> service(repository).getSectionLockers(PERIOD_ID, 1L));
+ () -> service(repository).getSectionDetail(PERIOD_ID, 1L));
assertEquals(LockerErrorCode.LOCKER_PERIOD_NOT_FOUND, e.getErrorCode());
}
@@ -139,7 +140,7 @@ void unknownSection() {
FakeLockerRepository repository = new FakeLockerRepository().withMissingSection();
BusinessException e = assertThrows(BusinessException.class,
- () -> service(repository).getSectionLockers(PERIOD_ID, 99L));
+ () -> service(repository).getSectionDetail(PERIOD_ID, 99L));
assertEquals(LockerErrorCode.LOCKER_SECTION_NOT_FOUND, e.getErrorCode());
}
@@ -246,13 +247,18 @@ public boolean existsPublishedPeriod(Long lockerPeriodId) {
}
@Override
- public boolean existsSection(Long sectionId) {
- return sectionExists;
+ public List findAllSections() {
+ return sections;
}
@Override
- public List findAllSections() {
- return sections;
+ public Optional findSectionById(Long sectionId) {
+ return sectionExists ? Optional.of(section(sectionId, "A-1")) : Optional.empty();
+ }
+
+ @Override
+ public Optional findLayoutBySectionId(Long sectionId) {
+ return Optional.empty();
}
@Override
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 9f718272..949b3805 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
@@ -6,6 +6,7 @@
import kr.ac.kookmin.stream.event.domain.locker.domain.Locker;
import kr.ac.kookmin.stream.event.domain.locker.domain.LockerPeriod;
import kr.ac.kookmin.stream.event.domain.locker.domain.LockerSection;
+import kr.ac.kookmin.stream.event.domain.locker.domain.LockerSectionLayout;
import kr.ac.kookmin.stream.event.domain.locker.repository.LockerRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Repository;
@@ -16,6 +17,7 @@ public class LockerRepositoryImpl implements LockerRepository {
private final LockerPeriodJpaRepository lockerPeriodJpaRepository;
private final LockerSectionJpaRepository lockerSectionJpaRepository;
+ private final LockerSectionLayoutJpaRepository lockerSectionLayoutJpaRepository;
private final LockerJpaRepository lockerJpaRepository;
@Override
@@ -23,11 +25,6 @@ public boolean existsPublishedPeriod(Long lockerPeriodId) {
return lockerPeriodJpaRepository.existsByIdAndIsPublishedTrue(lockerPeriodId);
}
- @Override
- public boolean existsSection(Long sectionId) {
- return lockerSectionJpaRepository.existsById(sectionId);
- }
-
@Override
public Optional findPublishedPeriodById(Long lockerPeriodId) {
return lockerPeriodJpaRepository.findByIdAndIsPublishedTrue(lockerPeriodId)
@@ -48,6 +45,18 @@ public List findAllSections() {
.toList();
}
+ @Override
+ public Optional findSectionById(Long sectionId) {
+ return lockerSectionJpaRepository.findById(sectionId)
+ .map(LockerSectionJpaEntity::toDomain);
+ }
+
+ @Override
+ public Optional findLayoutBySectionId(Long sectionId) {
+ return lockerSectionLayoutJpaRepository.findBySectionId(sectionId)
+ .map(LockerSectionLayoutJpaEntity::toDomain);
+ }
+
@Override
public List findAllLockers() {
return lockerJpaRepository.findAllByIsDeletedFalse().stream()
From 895dedf60211a2ea4e763118d731a707f07c7c8a Mon Sep 17 00:00:00 2001
From: Sumin Hwang <163857590+tnals0924@users.noreply.github.com>
Date: Tue, 6 Oct 2026 13:28:16 +0900
Subject: [PATCH 3/7] =?UTF-8?q?feat:=20=EA=B5=AC=EC=97=AD=20=EC=83=81?=
=?UTF-8?q?=EC=84=B8=20=EC=9D=91=EB=8B=B5=EC=9D=84=20layout=C2=B7=EC=82=AC?=
=?UTF-8?q?=EC=A7=84=20=EA=B8=B0=EB=B0=98=EC=9C=BC=EB=A1=9C=20=EA=B0=9C?=
=?UTF-8?q?=ED=8E=B8?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../api/app/event/locker/AppLockerApi.java | 8 +--
.../app/event/locker/AppLockerController.java | 13 ++++-
.../event/locker/response/LockerResponse.java | 8 +--
.../response/LockerSectionDetailResponse.java | 51 ++++++++++++++++---
4 files changed, 63 insertions(+), 17 deletions(-)
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 88e3ceb8..8663bd51 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
@@ -35,10 +35,12 @@ ApiResponse getSections(
@ParameterObject LockerPeriodParams params
);
- /** 구역에 속한 사물함의 배치 정보와 선택 가능 여부. */
+ /** 구역의 칸 배치 구조·사진과 사물함 선택 가능 여부. */
@Operation(summary = "사물함 구역 상세 조회",
- description = "구역에 속한 사물함의 배치도 위치와 선택 가능 여부를 조회한다. "
- + "사물함이 사용 가능한 상태이고 해당 회차에 신청되지 않은 경우에만 선택할 수 있다.")
+ description = "구역의 칸 배치 구조(layout)와 실제 사진, 구역에 속한 사물함의 선택 가능 여부를 조회한다. "
+ + "layout의 칸 번호는 lockers[].lockerNumber와 같다. 배치 구조를 아직 등록하지 않은 구역이면 "
+ + "layout과 photoUrl이 null이다. 사물함이 사용 가능한 상태이고 해당 회차에 신청되지 않은 경우에만 "
+ + "선택할 수 있다.")
@ApiErrorCode(type = CommonErrorCode.class, codes = {"INVALID_INPUT"})
@ApiErrorCode(type = LockerErrorCode.class, codes = {"LOCKER_PERIOD_NOT_FOUND", "LOCKER_SECTION_NOT_FOUND"})
ApiResponse getSectionLockers(
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 67e66a7a..19b752d2 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,6 +3,7 @@
import jakarta.validation.Valid;
import java.time.LocalDate;
import java.util.List;
+import java.util.Optional;
import java.util.Set;
import kr.ac.kookmin.stream.api.app.AppApiUser;
import kr.ac.kookmin.stream.api.app.event.locker.request.LockerApplyRequest;
@@ -14,9 +15,12 @@
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.LockerApplicationResult;
+import kr.ac.kookmin.stream.event.domain.locker.domain.LockerSectionDetail;
import kr.ac.kookmin.stream.event.domain.locker.domain.LockerSectionSummary;
import kr.ac.kookmin.stream.event.domain.locker.service.LockerApplicationService;
import kr.ac.kookmin.stream.event.domain.locker.service.LockerService;
+import kr.ac.kookmin.stream.file.domain.File;
+import kr.ac.kookmin.stream.file.service.FileService;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.ModelAttribute;
@@ -39,6 +43,7 @@ public class AppLockerController implements AppLockerApi {
private final LockerService lockerService;
private final LockerApplicationService lockerApplicationService;
+ private final FileService fileService;
@Override
@GetMapping("/sections")
@@ -63,13 +68,17 @@ public ApiResponse getSectionLockers(
@Valid @ModelAttribute LockerPeriodParams params
) {
Long lockerPeriodId = params.lockerPeriodId();
- List lockers = lockerService.getSectionDetail(lockerPeriodId, sectionId).lockers();
+ LockerSectionDetail detail = lockerService.getSectionDetail(lockerPeriodId, sectionId);
+ // 배치 구조를 아직 등록하지 않은 구역이면 사진도 없다
+ File photo = Optional.ofNullable(detail.layout())
+ .flatMap(layout -> fileService.findById(layout.getPhotoFileId()))
+ .orElse(null);
Set appliedLockerIds = lockerService.getAppliedLockerIds(lockerPeriodId);
Long myLockerId = lockerService.getLockerByMemberId(lockerPeriodId, apiUser.userId())
.map(Locker::getId)
.orElse(null);
- return ApiResponse.success(LockerSectionDetailResponse.of(lockers, appliedLockerIds, myLockerId));
+ return ApiResponse.success(LockerSectionDetailResponse.of(detail, photo, appliedLockerIds, myLockerId));
}
@Override
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 fb04cf58..990b77c2 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
@@ -5,10 +5,8 @@
public record LockerResponse(
Long lockerId,
- String lockerLabel,
int lockerNumber,
- int rowNo,
- int columnNo,
+ String lockerLabel,
boolean isAvailable,
boolean isMine
) {
@@ -20,10 +18,8 @@ public record LockerResponse(
public static LockerResponse of(Locker locker, boolean applied, Long myLockerId) {
return new LockerResponse(
locker.getId(),
- locker.getLockerLabel(),
locker.getLockerNumber(),
- locker.getRowNo(),
- locker.getColumnNo(),
+ locker.getLockerLabel(),
locker.isSelectable(applied),
Objects.equals(locker.getId(), myLockerId)
);
diff --git a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/event/locker/response/LockerSectionDetailResponse.java b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/event/locker/response/LockerSectionDetailResponse.java
index 4880c6a2..b5a640bb 100644
--- a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/event/locker/response/LockerSectionDetailResponse.java
+++ b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/event/locker/response/LockerSectionDetailResponse.java
@@ -1,22 +1,61 @@
package kr.ac.kookmin.stream.api.app.event.locker.response;
+import com.fasterxml.jackson.annotation.JsonRawValue;
+import io.swagger.v3.oas.annotations.media.Schema;
import java.util.List;
import java.util.Set;
-import kr.ac.kookmin.stream.event.domain.locker.domain.Locker;
+import kr.ac.kookmin.stream.api.common.StorageUrlBuilder;
+import kr.ac.kookmin.stream.event.domain.locker.domain.LockerSectionDetail;
+import kr.ac.kookmin.stream.event.domain.locker.domain.LockerSectionLayout;
+import kr.ac.kookmin.stream.file.domain.File;
-public record LockerSectionDetailResponse(List lockers) {
+/**
+ * @param layout 칸 배치 구조. 배치 구조를 아직 등록하지 않은 구역이면 {@code null}
+ * @param photoUrl 구역 실제 사진. 배치 구조를 아직 등록하지 않았거나 사진 파일이 삭제됐으면 {@code null}
+ */
+public record LockerSectionDetailResponse(
+ Long sectionId,
+ String section,
+ Layout layout,
+ String photoUrl,
+ List lockers
+) {
/**
+ * @param photo 구역 사진 파일. 배치 구조를 아직 등록하지 않았거나 파일이 삭제됐으면 {@code null}
* @param appliedLockerIds 해당 운영 회차에 이미 신청된 사물함 식별자
* @param myLockerId 조회한 회원이 신청한 사물함. 신청하지 않았으면 {@code null}
*/
public static LockerSectionDetailResponse of(
- List lockers,
+ LockerSectionDetail detail,
+ File photo,
Set appliedLockerIds,
Long myLockerId
) {
- return new LockerSectionDetailResponse(lockers.stream()
- .map(locker -> LockerResponse.of(locker, appliedLockerIds.contains(locker.getId()), myLockerId))
- .toList());
+ return new LockerSectionDetailResponse(
+ detail.section().getId(),
+ detail.section().getLabel(),
+ detail.layout() == null ? null : Layout.from(detail.layout()),
+ photo == null ? null : StorageUrlBuilder.build(photo.getFileKey()),
+ detail.lockers().stream()
+ .map(locker -> LockerResponse.of(locker, appliedLockerIds.contains(locker.getId()), myLockerId))
+ .toList()
+ );
+ }
+
+ /**
+ * @param version layout 형식 버전. 앱은 모르는 버전이면 그리지 않고 업데이트를 안내한다
+ * @param root 저장된 JSON을 해석하지 않고 그대로 끼워 넣는다
+ */
+ public record Layout(
+ int version,
+ @JsonRawValue
+ @Schema(type = "object", description = "root 블록 트리. 블록은 type으로 구분하고, 칸 번호는 lockers[].lockerNumber와 같다")
+ String root
+ ) {
+
+ public static Layout from(LockerSectionLayout layout) {
+ return new Layout(layout.getVersion(), layout.getLayout());
+ }
}
}
From cbdd4fab6483725f20e7ae1332eb23fb75f08225 Mon Sep 17 00:00:00 2001
From: Sumin Hwang <163857590+tnals0924@users.noreply.github.com>
Date: Tue, 6 Oct 2026 13:28:31 +0900
Subject: [PATCH 4/7] =?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=82=AC=EC=A7=84=20=ED=8C=8C=EC=9D=BC=20?=
=?UTF-8?q?=EC=B9=B4=ED=85=8C=EA=B3=A0=EB=A6=AC=20=EC=B6=94=EA=B0=80?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../java/kr/ac/kookmin/stream/file/domain/FileCategory.java | 3 ++-
.../kookmin/stream/file/service/impl/FileUploadPolicy.java | 6 ++++--
2 files changed, 6 insertions(+), 3 deletions(-)
diff --git a/core/domain/file/src/main/java/kr/ac/kookmin/stream/file/domain/FileCategory.java b/core/domain/file/src/main/java/kr/ac/kookmin/stream/file/domain/FileCategory.java
index 0fa223d5..2b11e125 100644
--- a/core/domain/file/src/main/java/kr/ac/kookmin/stream/file/domain/FileCategory.java
+++ b/core/domain/file/src/main/java/kr/ac/kookmin/stream/file/domain/FileCategory.java
@@ -5,5 +5,6 @@ public enum FileCategory {
NOTICE_ATTACHMENT,
NOTICE_IMAGE,
EVENT_IMAGE,
- ARCHIVE_IMAGE
+ ARCHIVE_IMAGE,
+ LOCKER_SECTION_PHOTO
}
diff --git a/core/domain/file/src/main/java/kr/ac/kookmin/stream/file/service/impl/FileUploadPolicy.java b/core/domain/file/src/main/java/kr/ac/kookmin/stream/file/service/impl/FileUploadPolicy.java
index 58d5f2b1..6466a7e8 100644
--- a/core/domain/file/src/main/java/kr/ac/kookmin/stream/file/service/impl/FileUploadPolicy.java
+++ b/core/domain/file/src/main/java/kr/ac/kookmin/stream/file/service/impl/FileUploadPolicy.java
@@ -21,7 +21,8 @@ FileCategory.TEMP, union(IMAGE_EXTENSIONS, ATTACHMENT_EXTENSIONS),
FileCategory.NOTICE_ATTACHMENT, ATTACHMENT_EXTENSIONS,
FileCategory.NOTICE_IMAGE, IMAGE_EXTENSIONS,
FileCategory.EVENT_IMAGE, IMAGE_EXTENSIONS,
- FileCategory.ARCHIVE_IMAGE, IMAGE_EXTENSIONS
+ FileCategory.ARCHIVE_IMAGE, IMAGE_EXTENSIONS,
+ FileCategory.LOCKER_SECTION_PHOTO, IMAGE_EXTENSIONS
);
private static final Map MAX_FILE_SIZE = Map.of(
@@ -29,7 +30,8 @@ FileCategory.TEMP, union(IMAGE_EXTENSIONS, ATTACHMENT_EXTENSIONS),
FileCategory.NOTICE_ATTACHMENT, ATTACHMENT_MAX_FILE_SIZE,
FileCategory.NOTICE_IMAGE, IMAGE_MAX_FILE_SIZE,
FileCategory.EVENT_IMAGE, IMAGE_MAX_FILE_SIZE,
- FileCategory.ARCHIVE_IMAGE, IMAGE_MAX_FILE_SIZE
+ FileCategory.ARCHIVE_IMAGE, IMAGE_MAX_FILE_SIZE,
+ FileCategory.LOCKER_SECTION_PHOTO, IMAGE_MAX_FILE_SIZE
);
private FileUploadPolicy() {}
From d73c70d6e0d25094b5707ed26d5956be4fd406e9 Mon Sep 17 00:00:00 2001
From: Sumin Hwang <163857590+tnals0924@users.noreply.github.com>
Date: Tue, 6 Oct 2026 15:23:17 +0900
Subject: [PATCH 5/7] =?UTF-8?q?refactor:=20=EA=B5=AC=EC=97=AD=20=EB=B0=B0?=
=?UTF-8?q?=EC=B9=98=20=ED=98=95=EC=8B=9D=20=EB=B2=84=EC=A0=84=20=EC=BB=AC?=
=?UTF-8?q?=EB=9F=BC=20=EB=A7=A4=ED=95=91=EC=9D=84=20columnDefinition?=
=?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=EB=B3=80=EA=B2=BD?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../stream/db/event/LockerSectionLayoutJpaEntity.java | 8 ++------
1 file changed, 2 insertions(+), 6 deletions(-)
diff --git a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/LockerSectionLayoutJpaEntity.java b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/LockerSectionLayoutJpaEntity.java
index 48afa0ae..6689bd42 100644
--- a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/LockerSectionLayoutJpaEntity.java
+++ b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/LockerSectionLayoutJpaEntity.java
@@ -31,12 +31,8 @@ public class LockerSectionLayoutJpaEntity extends BaseTimeEntity {
@Column(nullable = false, columnDefinition = "json")
private String layout;
- /**
- * layout 형식 버전. 낙관적 락용 {@code @Version}이 아니다.
- * 컬럼이 SMALLINT라 스키마 검증이 INTEGER를 기대하지 않도록 JDBC 타입을 지정한다.
- */
- @JdbcTypeCode(SqlTypes.SMALLINT)
- @Column(nullable = false)
+ /** layout 형식 버전. 낙관적 락용 {@code @Version}이 아니다. */
+ @Column(nullable = false, columnDefinition = "SMALLINT")
private int version;
@Column(name = "photo_file_id", nullable = false)
From 6823df89f5974eef7668cc9c1a3a6579aa2634f7 Mon Sep 17 00:00:00 2001
From: Sumin Hwang <163857590+tnals0924@users.noreply.github.com>
Date: Tue, 6 Oct 2026 15:23:17 +0900
Subject: [PATCH 6/7] =?UTF-8?q?refactor:=20=EA=B5=AC=EC=97=AD=20=EC=83=81?=
=?UTF-8?q?=EC=84=B8=20=EC=A1=B0=ED=9A=8C=EC=9D=98=20=EC=9D=BD=EA=B8=B0=20?=
=?UTF-8?q?=EC=A0=84=EC=9A=A9=20=ED=8A=B8=EB=9E=9C=EC=9E=AD=EC=85=98=20?=
=?UTF-8?q?=EC=A0=9C=EA=B1=B0?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../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 f4d02f9f..02c82ecf 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
@@ -45,7 +45,6 @@ public List getSections(Long lockerPeriodId) {
}
@Override
- @Transactional(readOnly = true)
public LockerSectionDetail getSectionDetail(Long lockerPeriodId, Long sectionId) {
requirePublishedPeriod(lockerPeriodId);
LockerSection section = lockerRepository.findSectionById(sectionId)
From eeeab6fd7fd2254eb7e9703acdcbece55741cb82 Mon Sep 17 00:00:00 2001
From: Sumin Hwang <163857590+tnals0924@users.noreply.github.com>
Date: Tue, 6 Oct 2026 16:06:51 +0900
Subject: [PATCH 7/7] =?UTF-8?q?refactor:=20=EA=B5=AC=EC=97=AD=20=EC=82=AC?=
=?UTF-8?q?=EC=A7=84=20URL=20=EC=A1=B0=EB=A6=BD=EC=9D=98=20=EC=A4=91?=
=?UTF-8?q?=EB=B3=B5=20null=20=EC=B2=B4=ED=81=AC=20=EC=A0=9C=EA=B1=B0?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../event/locker/response/LockerSectionDetailResponse.java | 2 +-
.../kr/ac/kookmin/stream/api/common/StorageUrlBuilder.java | 7 +++++++
2 files changed, 8 insertions(+), 1 deletion(-)
diff --git a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/event/locker/response/LockerSectionDetailResponse.java b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/event/locker/response/LockerSectionDetailResponse.java
index b5a640bb..a8868a75 100644
--- a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/event/locker/response/LockerSectionDetailResponse.java
+++ b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/event/locker/response/LockerSectionDetailResponse.java
@@ -36,7 +36,7 @@ public static LockerSectionDetailResponse of(
detail.section().getId(),
detail.section().getLabel(),
detail.layout() == null ? null : Layout.from(detail.layout()),
- photo == null ? null : StorageUrlBuilder.build(photo.getFileKey()),
+ StorageUrlBuilder.build(photo),
detail.lockers().stream()
.map(locker -> LockerResponse.of(locker, appliedLockerIds.contains(locker.getId()), myLockerId))
.toList()
diff --git a/api/common-api/src/main/java/kr/ac/kookmin/stream/api/common/StorageUrlBuilder.java b/api/common-api/src/main/java/kr/ac/kookmin/stream/api/common/StorageUrlBuilder.java
index 90eb44fe..6306aeaa 100644
--- a/api/common-api/src/main/java/kr/ac/kookmin/stream/api/common/StorageUrlBuilder.java
+++ b/api/common-api/src/main/java/kr/ac/kookmin/stream/api/common/StorageUrlBuilder.java
@@ -9,6 +9,13 @@ public static String build(String fileKey) {
return fileKey == null ? null : ApiConstants.STORAGE_BASE_URL + "/" + fileKey;
}
+ /**
+ * file이 null이면(없거나 삭제된 파일) null을 돌려준다.
+ */
+ public static String build(File file) {
+ return file == null ? null : build(file.getFileKey());
+ }
+
/**
* fileId가 null이거나 filesById에 없으면(삭제된 파일) null을 돌려준다.
*/