From e7c51efb7d5df26473896e50ca5754a08613e0d2 Mon Sep 17 00:00:00 2001 From: sangrae Date: Sat, 17 Oct 2026 17:17:32 +0900 Subject: [PATCH 1/3] =?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=EC=97=AC=EB=9F=AC=20?= =?UTF-8?q?=EC=9E=A5=20=EC=A7=80=EC=9B=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../api/app/event/locker/AppLockerApi.java | 4 +-- .../app/event/locker/AppLockerController.java | 12 ++++++--- .../response/LockerSectionDetailResponse.java | 27 ++++++++++++++----- .../locker/domain/LockerSectionLayout.java | 14 +++++++--- .../event/LockerSectionLayoutJpaEntity.java | 12 ++++++--- ..._locker_section_layout_photo_with_list.sql | 14 ++++++++++ 6 files changed, 64 insertions(+), 19 deletions(-) create mode 100644 infrastructure/db/src/main/resources/db/migration/V21__replace_locker_section_layout_photo_with_list.sql 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 cf75b921..656cc7a8 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 @@ -39,8 +39,8 @@ ApiResponse getSections( @Operation(summary = "사물함 구역 상세 조회", description = "구역의 칸 배치 구조(layout)와 실제 사진, 구역에 속한 사물함의 선택 가능 여부를 조회한다. " + "layout의 칸 번호는 lockers[].lockerNumber와 같다. 배치 구조를 아직 등록하지 않은 구역이면 " - + "layout과 photoUrl이 null이다. 사물함이 사용 가능한 상태이고 해당 회차에 신청되지 않은 경우에만 " - + "선택할 수 있다.") + + "layout이 null이고 photoUrls가 빈 배열이다. 사진은 노출 순서대로 담기며 삭제된 파일은 빠진다. " + + "사물함이 사용 가능한 상태이고 해당 회차에 신청되지 않은 경우에만 선택할 수 있다.") @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 19b752d2..bc4b5e22 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.Map; import java.util.Optional; import java.util.Set; import kr.ac.kookmin.stream.api.app.AppApiUser; @@ -16,6 +17,7 @@ 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.LockerSectionLayout; 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; @@ -70,15 +72,17 @@ public ApiResponse getSectionLockers( Long lockerPeriodId = params.lockerPeriodId(); LockerSectionDetail detail = lockerService.getSectionDetail(lockerPeriodId, sectionId); // 배치 구조를 아직 등록하지 않은 구역이면 사진도 없다 - File photo = Optional.ofNullable(detail.layout()) - .flatMap(layout -> fileService.findById(layout.getPhotoFileId())) - .orElse(null); + Map photosById = Optional.ofNullable(detail.layout()) + .map(LockerSectionLayout::getPhotoFileIds) + .filter(photoFileIds -> !photoFileIds.isEmpty()) + .map(fileService::findAllByIdIn) + .orElseGet(Map::of); Set appliedLockerIds = lockerService.getAppliedLockerIds(lockerPeriodId); Long myLockerId = lockerService.getLockerByMemberId(lockerPeriodId, apiUser.userId()) .map(Locker::getId) .orElse(null); - return ApiResponse.success(LockerSectionDetailResponse.of(detail, photo, appliedLockerIds, myLockerId)); + return ApiResponse.success(LockerSectionDetailResponse.of(detail, photosById, appliedLockerIds, myLockerId)); } @Override 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 a8868a75..ecff655e 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 @@ -3,6 +3,8 @@ import com.fasterxml.jackson.annotation.JsonRawValue; import io.swagger.v3.oas.annotations.media.Schema; import java.util.List; +import java.util.Map; +import java.util.Objects; import java.util.Set; import kr.ac.kookmin.stream.api.common.StorageUrlBuilder; import kr.ac.kookmin.stream.event.domain.locker.domain.LockerSectionDetail; @@ -10,25 +12,25 @@ import kr.ac.kookmin.stream.file.domain.File; /** - * @param layout 칸 배치 구조. 배치 구조를 아직 등록하지 않은 구역이면 {@code null} - * @param photoUrl 구역 실제 사진. 배치 구조를 아직 등록하지 않았거나 사진 파일이 삭제됐으면 {@code null} + * @param layout 칸 배치 구조. 배치 구조를 아직 등록하지 않은 구역이면 {@code null} + * @param photoUrls 구역 실제 사진. 노출 순서대로 담고, 없으면 빈 목록이다 */ public record LockerSectionDetailResponse( Long sectionId, String section, Layout layout, - String photoUrl, + List photoUrls, List lockers ) { /** - * @param photo 구역 사진 파일. 배치 구조를 아직 등록하지 않았거나 파일이 삭제됐으면 {@code null} + * @param photosById 구역 사진 파일. 삭제된 파일은 키가 없어 목록에서 조용히 빠진다 * @param appliedLockerIds 해당 운영 회차에 이미 신청된 사물함 식별자 * @param myLockerId 조회한 회원이 신청한 사물함. 신청하지 않았으면 {@code null} */ public static LockerSectionDetailResponse of( LockerSectionDetail detail, - File photo, + Map photosById, Set appliedLockerIds, Long myLockerId ) { @@ -36,13 +38,26 @@ public static LockerSectionDetailResponse of( detail.section().getId(), detail.section().getLabel(), detail.layout() == null ? null : Layout.from(detail.layout()), - StorageUrlBuilder.build(photo), + toPhotoUrls(detail, photosById), detail.lockers().stream() .map(locker -> LockerResponse.of(locker, appliedLockerIds.contains(locker.getId()), myLockerId)) .toList() ); } + /** + * 배치 구조를 등록하지 않은 구역이면 사진도 없다. 학생 앱은 사진을 보여주기만 하므로 파일 식별자 없이 URL만 담는다. + */ + private static List toPhotoUrls(LockerSectionDetail detail, Map photosById) { + if (detail.layout() == null) { + return List.of(); + } + return detail.layout().getPhotoFileIds().stream() + .map(fileId -> StorageUrlBuilder.build(fileId, photosById)) + .filter(Objects::nonNull) + .toList(); + } + /** * @param version layout 형식 버전. 앱은 모르는 버전이면 그리지 않고 업데이트를 안내한다 * @param root 저장된 JSON을 해석하지 않고 그대로 끼워 넣는다 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 index 83632b71..f8cc7002 100644 --- 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 @@ -1,5 +1,6 @@ package kr.ac.kookmin.stream.event.domain.locker.domain; +import java.util.List; import lombok.AccessLevel; import lombok.AllArgsConstructor; import lombok.EqualsAndHashCode; @@ -22,9 +23,16 @@ public class LockerSectionLayout { private String layout; /** layout 형식 버전. 블록 종류·필드가 바뀌면 올려서 옛 앱이 모르는 형식을 알아채게 한다. */ private int version; - private Long photoFileId; + /** 구역 실제 사진. 배열 순서가 노출 순서다. 등록하지 않았으면 빈 목록이다. */ + private List photoFileIds; - public static LockerSectionLayout of(Long id, Long sectionId, String layout, int version, Long photoFileId) { - return new LockerSectionLayout(id, sectionId, layout, version, photoFileId); + public static LockerSectionLayout of( + Long id, + Long sectionId, + String layout, + int version, + List photoFileIds + ) { + return new LockerSectionLayout(id, sectionId, layout, version, photoFileIds); } } 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 6689bd42..d3407e85 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 @@ -6,6 +6,7 @@ import jakarta.persistence.GenerationType; import jakarta.persistence.Id; import jakarta.persistence.Table; +import java.util.List; import kr.ac.kookmin.stream.db.common.BaseTimeEntity; import kr.ac.kookmin.stream.event.domain.locker.domain.LockerSectionLayout; import lombok.AccessLevel; @@ -35,15 +36,17 @@ public class LockerSectionLayoutJpaEntity extends BaseTimeEntity { @Column(nullable = false, columnDefinition = "SMALLINT") private int version; - @Column(name = "photo_file_id", nullable = false) - private Long photoFileId; + /** 구역 실제 사진. 배열 순서가 노출 순서다. 파일 목록을 JSON으로 두는 건 notices.image_ids와 같은 방식이다. */ + @JdbcTypeCode(SqlTypes.JSON) + @Column(name = "photo_file_ids", columnDefinition = "json") + private List photoFileIds; private LockerSectionLayoutJpaEntity(LockerSectionLayout sectionLayout) { this.id = sectionLayout.getId(); this.sectionId = sectionLayout.getSectionId(); this.layout = sectionLayout.getLayout(); this.version = sectionLayout.getVersion(); - this.photoFileId = sectionLayout.getPhotoFileId(); + this.photoFileIds = sectionLayout.getPhotoFileIds(); } public static LockerSectionLayoutJpaEntity from(LockerSectionLayout sectionLayout) { @@ -51,6 +54,7 @@ public static LockerSectionLayoutJpaEntity from(LockerSectionLayout sectionLayou } public LockerSectionLayout toDomain() { - return LockerSectionLayout.of(id, sectionId, layout, version, photoFileId); + // 사진을 한 장도 등록하지 않은 구역은 컬럼이 NULL이다. 읽는 쪽이 매번 null을 보지 않도록 빈 목록으로 바꾼다 + return LockerSectionLayout.of(id, sectionId, layout, version, photoFileIds == null ? List.of() : photoFileIds); } } diff --git a/infrastructure/db/src/main/resources/db/migration/V21__replace_locker_section_layout_photo_with_list.sql b/infrastructure/db/src/main/resources/db/migration/V21__replace_locker_section_layout_photo_with_list.sql new file mode 100644 index 00000000..412e4993 --- /dev/null +++ b/infrastructure/db/src/main/resources/db/migration/V21__replace_locker_section_layout_photo_with_list.sql @@ -0,0 +1,14 @@ +-- 구역 사진을 여러 장 저장한다. 구역당 한 장 전제로 V15에서 단일 컬럼을 뒀는데, 실제로 두 장인 구역이 있다. +-- 배열 순서가 노출 순서다. +-- +-- 저장소의 다른 파일 목록 컬럼(notices.image_ids·attachment_ids, events.image_ids, archives.image_ids)과 같은 +-- JSON NULL로 둔다. JSON으로 바꾸면 "반드시 한 장"은 DB가 못 지킨다 — 빈 배열은 NULL이 아니라서 NOT NULL을 +-- 통과한다. 최소 한 장 검증은 구역 쓰기 API에서 한다. +ALTER TABLE locker_section_layouts + ADD COLUMN photo_file_ids JSON NULL; + +UPDATE locker_section_layouts +SET photo_file_ids = JSON_ARRAY(photo_file_id); + +ALTER TABLE locker_section_layouts + DROP COLUMN photo_file_id; From 7ca68c044ecc5918b1f2809f4e99f189c686e782 Mon Sep 17 00:00:00 2001 From: sangrae Date: Sat, 17 Oct 2026 17:20:33 +0900 Subject: [PATCH 2/3] =?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=AA=A9=EB=A1=9D=EC=97=90=20=ED=9A=8C?= =?UTF-8?q?=EC=B0=A8=20=EB=B0=B0=EC=A0=95=20=EC=88=98=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/LockerSectionSummary.java | 7 ++++++- .../domain/locker/service/impl/LockerServiceImpl.java | 5 +++++ 2 files changed, 11 insertions(+), 1 deletion(-) 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 ddc68180..64891e90 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,13 +1,18 @@ package kr.ac.kookmin.stream.event.domain.locker.domain; /** - * 사물함 구역 목록 한 건. 전체·선택 가능 수는 저장값이 아니라 조회 시점에 센다. + * 사물함 구역 목록 한 건. 세 개수 모두 저장값이 아니라 조회 시점에 센다. + * + * @param availableCount 선택 가능한 사물함 수. 사용 중지된 사물함과 이 회차에 배정된 사물함은 빠진다 + * @param totalCount 구역의 전체 사물함 수. 사용 중지된 사물함도 포함한다 + * @param assignedCount 이 회차에 배정된 사물함 수 */ public record LockerSectionSummary( Long sectionId, String label, int availableCount, int totalCount, + int assignedCount, SectionAvailabilityStatus availabilityStatus ) { } 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 02c82ecf..e0dcdbab 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 @@ -89,12 +89,17 @@ private LockerSectionSummary summarize( int availableCount = (int) lockers.stream() .filter(locker -> locker.isSelectable(appliedLockerIds.contains(locker.getId()))) .count(); + // 사용 중지된 사물함도 배정돼 있으면 센다. 배정 후 고장 난 칸이라 운영진이 다른 자리를 줘야 하는 대상이다 + int assignedCount = (int) lockers.stream() + .filter(locker -> appliedLockerIds.contains(locker.getId())) + .count(); return new LockerSectionSummary( section.getId(), section.getLabel(), availableCount, totalCount, + assignedCount, SectionAvailabilityStatus.from(availableCount, totalCount) ); } From baa4847d01c47fbe6bac89518b72d8256f0c81b6 Mon Sep 17 00:00:00 2001 From: sangrae Date: Mon, 19 Oct 2026 00:05:49 +0900 Subject: [PATCH 3/3] =?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=AA=A9=EB=A1=9D=C2=B7=EC=83=81=EC=84=B8?= =?UTF-8?q?=20=EA=B4=80=EB=A6=AC=EC=9E=90=20API=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../event/locker/AdminLockerSectionApi.java | 50 +++++++ .../locker/AdminLockerSectionController.java | 59 +++++++++ .../locker/request/LockerPeriodParams.java | 19 +++ .../AdminLockerSectionDetailResponse.java | 122 ++++++++++++++++++ .../AdminLockerSectionListResponse.java | 37 ++++++ .../app/event/locker/AppLockerController.java | 9 +- 6 files changed, 289 insertions(+), 7 deletions(-) create mode 100644 api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/locker/AdminLockerSectionApi.java create mode 100644 api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/locker/AdminLockerSectionController.java create mode 100644 api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/locker/request/LockerPeriodParams.java create mode 100644 api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/locker/response/AdminLockerSectionDetailResponse.java create mode 100644 api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/locker/response/AdminLockerSectionListResponse.java diff --git a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/locker/AdminLockerSectionApi.java b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/locker/AdminLockerSectionApi.java new file mode 100644 index 00000000..37fe8ae5 --- /dev/null +++ b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/locker/AdminLockerSectionApi.java @@ -0,0 +1,50 @@ +package kr.ac.kookmin.stream.api.admin.event.locker; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import kr.ac.kookmin.stream.api.admin.AdminApiUser; +import kr.ac.kookmin.stream.api.admin.event.locker.request.LockerPeriodParams; +import kr.ac.kookmin.stream.api.admin.event.locker.response.AdminLockerSectionDetailResponse; +import kr.ac.kookmin.stream.api.admin.event.locker.response.AdminLockerSectionListResponse; +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 AdminLockerSectionController}가 맡는다. + *

+ * 접근은 {@code ADMIN} role이면 된다. 부서 제한은 두지 않았다. + *

+ * 구역은 운영 회차에 묶여 있지 않다. 요청의 회차는 "어느 회차의 배정 현황을 볼지"를 정하는 기준이다. + */ +@Tag(name = "사물함 구역 관리", description = "운영진 사물함 구역 목록·상세 조회") +public interface AdminLockerSectionApi { + + /** 구역별 전체·선택 가능·배정 사물함 수. */ + @Operation(summary = "사물함 구역 목록 조회", + description = "운영 회차를 기준으로 구역별 전체 사물함 수, 선택 가능한 사물함 수, 배정된 사물함 수를 조회한다. " + + "사용 중지된 사물함과 그 회차에 배정된 사물함은 선택 가능 수에서 빠진다. " + + "배정 수는 배정된 뒤 사용 중지된 사물함도 센다. 구역은 식별자 오름차순으로 반환한다.") + @ApiErrorCode(type = CommonErrorCode.class, codes = {"INVALID_INPUT"}) + @ApiErrorCode(type = LockerErrorCode.class, codes = {"LOCKER_PERIOD_NOT_FOUND"}) + ApiResponse getLockerSections( + AdminApiUser apiUser, + @ParameterObject LockerPeriodParams params + ); + + /** 구역의 칸 배치·사진과 사물함별 상태. */ + @Operation(summary = "사물함 구역 상세 조회", + description = "운영 회차를 기준으로 구역의 칸 배치 구조(layout), 구역 사진, 사물함별 상태를 조회한다. " + + "layout은 저장된 원문을 그대로 돌려주며 칸 번호는 lockers[].lockerNumber와 같다. " + + "배치 구조를 아직 등록하지 않은 구역이면 layout이 null이고 photos가 빈 배열이다. " + + "사물함 상태는 물리 상태와 그 회차의 배정 여부를 합친 값이고, 배정된 뒤 사용 중지된 칸은 ASSIGNED다.") + @ApiErrorCode(type = CommonErrorCode.class, codes = {"INVALID_INPUT"}) + @ApiErrorCode(type = LockerErrorCode.class, codes = {"LOCKER_PERIOD_NOT_FOUND", "LOCKER_SECTION_NOT_FOUND"}) + ApiResponse getLockerSection( + AdminApiUser apiUser, + Long sectionId, + @ParameterObject LockerPeriodParams params + ); +} diff --git a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/locker/AdminLockerSectionController.java b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/locker/AdminLockerSectionController.java new file mode 100644 index 00000000..a001e5fe --- /dev/null +++ b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/locker/AdminLockerSectionController.java @@ -0,0 +1,59 @@ +package kr.ac.kookmin.stream.api.admin.event.locker; + +import jakarta.validation.Valid; +import java.util.List; +import java.util.Map; +import java.util.Set; +import kr.ac.kookmin.stream.api.admin.AdminApiUser; +import kr.ac.kookmin.stream.api.admin.event.locker.request.LockerPeriodParams; +import kr.ac.kookmin.stream.api.admin.event.locker.response.AdminLockerSectionDetailResponse; +import kr.ac.kookmin.stream.api.admin.event.locker.response.AdminLockerSectionListResponse; +import kr.ac.kookmin.stream.api.common.dto.ApiResponse; +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.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; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/v1/admin/lockers/sections") +@RequiredArgsConstructor +public class AdminLockerSectionController implements AdminLockerSectionApi { + + private final LockerService lockerService; + private final FileService fileService; + + @Override + @GetMapping + public ApiResponse getLockerSections( + AdminApiUser apiUser, + @Valid @ModelAttribute LockerPeriodParams params + ) { + List sections = lockerService.getSections(params.lockerPeriodId()); + return ApiResponse.success(AdminLockerSectionListResponse.from(sections)); + } + + @Override + @GetMapping("/{sectionId}") + public ApiResponse getLockerSection( + AdminApiUser apiUser, + @PathVariable Long sectionId, + @Valid @ModelAttribute LockerPeriodParams params + ) { + Long lockerPeriodId = params.lockerPeriodId(); + LockerSectionDetail detail = lockerService.getSectionDetail(lockerPeriodId, sectionId); + // 배치 구조를 아직 등록하지 않은 구역이면 사진도 없다 + Map photosById = fileService.findAllByIdIn( + detail.layout() == null ? List.of() : detail.layout().getPhotoFileIds()); + Set appliedLockerIds = lockerService.getAppliedLockerIds(lockerPeriodId); + + return ApiResponse.success( + AdminLockerSectionDetailResponse.of(detail, photosById, appliedLockerIds)); + } +} diff --git a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/locker/request/LockerPeriodParams.java b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/locker/request/LockerPeriodParams.java new file mode 100644 index 00000000..34e1a8b0 --- /dev/null +++ b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/locker/request/LockerPeriodParams.java @@ -0,0 +1,19 @@ +package kr.ac.kookmin.stream.api.admin.event.locker.request; + +import jakarta.validation.constraints.NotNull; + +/** + * 구역 조회의 기준 회차. 구역은 운영 회차에 묶여 있지 않고, 회차는 "어느 회차의 배정 현황을 볼지"를 정한다. + *

+ * 필드가 하나뿐이지만 {@code @RequestParam}으로 받지 않는다. {@code GlobalExceptionHandler}가 + * {@code MissingServletRequestParameterException}을 다루지 않아 값이 빠지면 500이 되고, + * {@code @Valid @ModelAttribute}여야 400 {@code INVALID_INPUT}으로 나간다. + * + * @param lockerPeriodId 조회할 사물함 운영 회차 식별자 + */ +public record LockerPeriodParams( + + @NotNull(message = "사물함 운영 회차를 입력해 주세요.") + Long lockerPeriodId +) { +} diff --git a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/locker/response/AdminLockerSectionDetailResponse.java b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/locker/response/AdminLockerSectionDetailResponse.java new file mode 100644 index 00000000..84389500 --- /dev/null +++ b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/locker/response/AdminLockerSectionDetailResponse.java @@ -0,0 +1,122 @@ +package kr.ac.kookmin.stream.api.admin.event.locker.response; + +import com.fasterxml.jackson.annotation.JsonRawValue; +import io.swagger.v3.oas.annotations.media.Schema; +import java.util.List; +import java.util.Map; +import java.util.Set; +import kr.ac.kookmin.stream.api.common.StorageUrlBuilder; +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.LockerSectionLayout; +import kr.ac.kookmin.stream.event.domain.locker.domain.LockerStatus; +import kr.ac.kookmin.stream.file.domain.File; + +/** + * @param layout 칸 배치 구조. 배치 구조를 아직 등록하지 않은 구역이면 {@code null} + * @param photos 구역 실제 사진. 노출 순서대로 담고, 없으면 빈 목록이다 + */ +public record AdminLockerSectionDetailResponse( + Long sectionId, + String section, + Layout layout, + List photos, + List lockers +) { + + /** + * @param photosById 구역 사진 파일. 삭제된 파일은 키가 없어 목록에서 조용히 빠진다 + * @param appliedLockerIds 해당 운영 회차에 이미 신청된 사물함 식별자 + */ + public static AdminLockerSectionDetailResponse of( + LockerSectionDetail detail, + Map photosById, + Set appliedLockerIds + ) { + return new AdminLockerSectionDetailResponse( + detail.section().getId(), + detail.section().getLabel(), + detail.layout() == null ? null : Layout.from(detail.layout()), + toPhotos(detail, photosById), + detail.lockers().stream() + .map(locker -> LockerItem.from(locker, appliedLockerIds.contains(locker.getId()))) + .toList() + ); + } + + /** 배치 구조를 등록하지 않은 구역이면 사진도 없다. */ + private static List toPhotos(LockerSectionDetail detail, Map photosById) { + if (detail.layout() == null) { + return List.of(); + } + return detail.layout().getPhotoFileIds().stream() + .filter(photosById::containsKey) + .map(fileId -> Photo.from(fileId, photosById.get(fileId))) + .toList(); + } + + /** + * @param version layout 형식 버전 + * @param root 저장된 JSON을 해석하지 않고 그대로 끼워 넣는다 + */ + public record Layout( + int version, + @JsonRawValue + @Schema(type = "object", description = "root 블록 트리. 칸 번호는 lockers[].lockerNumber와 같다") + String root + ) { + + public static Layout from(LockerSectionLayout layout) { + return new Layout(layout.getVersion(), layout.getLayout()); + } + } + + /** 운영진은 사진을 지우거나 순서를 바꿀 때 파일 식별자가 필요하므로 URL과 함께 내려준다. */ + public record Photo(Long fileId, String fileUrl) { + + public static Photo from(Long fileId, File file) { + return new Photo(fileId, StorageUrlBuilder.build(file.getFileKey())); + } + } + + /** 도메인 {@link Locker}와 이름이 겹치지 않도록 {@code LockerItem}으로 둔다. */ + public record LockerItem( + Long lockerId, + int lockerNumber, + String lockerLabel, + LockerDisplayStatus status + ) { + + /** @param applied 해당 운영 회차에 이 사물함이 이미 신청되었는지 */ + public static LockerItem from(Locker locker, boolean applied) { + return new LockerItem( + locker.getId(), + locker.getLockerNumber(), + locker.getLockerLabel(), + LockerDisplayStatus.from(locker.getStatus(), applied) + ); + } + } + + /** + * 운영진 화면에 칸을 어떻게 그릴지. 저장되는 물리 상태({@link LockerStatus})와 회차 배정 여부를 합친 값이라 + * 이 응답에서만 쓰고 도메인으로 올리지 않는다({@code coding-style.md} 2-2절). + */ + public enum LockerDisplayStatus { + + AVAILABLE, + DISABLED, + ASSIGNED; + + /** + * 배정된 뒤 사용 중지된 칸은 {@code ASSIGNED}로 본다. 운영진이 그 학생에게 다른 자리를 줘야 하는 상황이라 + * 배정 사실이 물리 상태보다 먼저 보여야 한다. + */ + public static LockerDisplayStatus from(LockerStatus status, boolean applied) { + if (applied) { + return ASSIGNED; + } + return status == LockerStatus.AVAILABLE ? AVAILABLE : DISABLED; + } + } +} diff --git a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/locker/response/AdminLockerSectionListResponse.java b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/locker/response/AdminLockerSectionListResponse.java new file mode 100644 index 00000000..957fdf17 --- /dev/null +++ b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/locker/response/AdminLockerSectionListResponse.java @@ -0,0 +1,37 @@ +package kr.ac.kookmin.stream.api.admin.event.locker.response; + +import java.util.List; +import kr.ac.kookmin.stream.event.domain.locker.domain.LockerSectionSummary; + +public record AdminLockerSectionListResponse(List

sections) { + + public static AdminLockerSectionListResponse from(List summaries) { + return new AdminLockerSectionListResponse(summaries.stream() + .map(Section::from) + .toList()); + } + + /** + * @param totalCount 구역 내 전체 사물함 수. 사용 중지된 사물함도 포함한다 + * @param availableCount 선택 가능한 사물함 수. 사용 중지된 사물함과 이 회차에 배정된 사물함은 빠진다 + * @param assignedCount 이 회차에 배정된 사물함 수. 배정된 뒤 사용 중지된 사물함도 센다 + */ + public record Section( + Long sectionId, + String section, + int totalCount, + int availableCount, + int assignedCount + ) { + + public static Section from(LockerSectionSummary summary) { + return new Section( + summary.sectionId(), + summary.label(), + summary.totalCount(), + summary.availableCount(), + summary.assignedCount() + ); + } + } +} 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 bc4b5e22..454e54d5 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 @@ -4,7 +4,6 @@ import java.time.LocalDate; import java.util.List; import java.util.Map; -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; @@ -17,7 +16,6 @@ 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.LockerSectionLayout; 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; @@ -72,11 +70,8 @@ public ApiResponse getSectionLockers( Long lockerPeriodId = params.lockerPeriodId(); LockerSectionDetail detail = lockerService.getSectionDetail(lockerPeriodId, sectionId); // 배치 구조를 아직 등록하지 않은 구역이면 사진도 없다 - Map photosById = Optional.ofNullable(detail.layout()) - .map(LockerSectionLayout::getPhotoFileIds) - .filter(photoFileIds -> !photoFileIds.isEmpty()) - .map(fileService::findAllByIdIn) - .orElseGet(Map::of); + Map photosById = fileService.findAllByIdIn( + detail.layout() == null ? List.of() : detail.layout().getPhotoFileIds()); Set appliedLockerIds = lockerService.getAppliedLockerIds(lockerPeriodId); Long myLockerId = lockerService.getLockerByMemberId(lockerPeriodId, apiUser.userId()) .map(Locker::getId)