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 3cd79dae..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.getSectionLockers(lockerPeriodId, sectionId); + 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..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 @@ -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()), + StorageUrlBuilder.build(photo), + 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()); + } } } 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을 돌려준다. */ 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/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/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..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 @@ -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,16 @@ public List getSections(Long lockerPeriodId) { } @Override - public List getSectionLockers(Long lockerPeriodId, Long sectionId) { + 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 +105,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/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() {} 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() 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..6689bd42 --- /dev/null +++ b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/LockerSectionLayoutJpaEntity.java @@ -0,0 +1,56 @@ +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}이 아니다. */ + @Column(nullable = false, columnDefinition = "SMALLINT") + 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);