Repository navigation
[Feat/#132] 사물함 구역 목록·상세 관리자 API 추가 #141
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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}가 맡는다. | ||
| * <p> | ||
| * 접근은 {@code ADMIN} role이면 된다. 부서 제한은 두지 않았다. | ||
| * <p> | ||
| * 구역은 운영 회차에 묶여 있지 않다. 요청의 회차는 "어느 회차의 배정 현황을 볼지"를 정하는 기준이다. | ||
| */ | ||
| @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<AdminLockerSectionListResponse> 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<AdminLockerSectionDetailResponse> getLockerSection( | ||
| AdminApiUser apiUser, | ||
| Long sectionId, | ||
| @ParameterObject LockerPeriodParams params | ||
| ); | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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; | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
규칙 말고도 동작 차이가 하나 있어요. 구역별 개수를 세는 로직은 |
||
| private final FileService fileService; | ||
|
|
||
| @Override | ||
| @GetMapping | ||
| public ApiResponse<AdminLockerSectionListResponse> getLockerSections( | ||
| AdminApiUser apiUser, | ||
| @Valid @ModelAttribute LockerPeriodParams params | ||
| ) { | ||
| List<LockerSectionSummary> sections = lockerService.getSections(params.lockerPeriodId()); | ||
| return ApiResponse.success(AdminLockerSectionListResponse.from(sections)); | ||
| } | ||
|
|
||
| @Override | ||
| @GetMapping("/{sectionId}") | ||
| public ApiResponse<AdminLockerSectionDetailResponse> getLockerSection( | ||
| AdminApiUser apiUser, | ||
| @PathVariable Long sectionId, | ||
| @Valid @ModelAttribute LockerPeriodParams params | ||
| ) { | ||
| Long lockerPeriodId = params.lockerPeriodId(); | ||
| LockerSectionDetail detail = lockerService.getSectionDetail(lockerPeriodId, sectionId); | ||
| // 배치 구조를 아직 등록하지 않은 구역이면 사진도 없다 | ||
| Map<Long, File> photosById = fileService.findAllByIdIn( | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
| detail.layout() == null ? List.of() : detail.layout().getPhotoFileIds()); | ||
| Set<Long> appliedLockerIds = lockerService.getAppliedLockerIds(lockerPeriodId); | ||
|
|
||
| return ApiResponse.success( | ||
| AdminLockerSectionDetailResponse.of(detail, photosById, appliedLockerIds)); | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,19 @@ | ||
| package kr.ac.kookmin.stream.api.admin.event.locker.request; | ||
|
|
||
| import jakarta.validation.constraints.NotNull; | ||
|
|
||
| /** | ||
| * 구역 조회의 기준 회차. 구역은 운영 회차에 묶여 있지 않고, 회차는 "어느 회차의 배정 현황을 볼지"를 정한다. | ||
| * <p> | ||
| * 필드가 하나뿐이지만 {@code @RequestParam}으로 받지 않는다. {@code GlobalExceptionHandler}가 | ||
| * {@code MissingServletRequestParameterException}을 다루지 않아 값이 빠지면 500이 되고, | ||
| * {@code @Valid @ModelAttribute}여야 400 {@code INVALID_INPUT}으로 나간다. | ||
| * | ||
| * @param lockerPeriodId 조회할 사물함 운영 회차 식별자 | ||
| */ | ||
| public record LockerPeriodParams( | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
|
|
||
| @NotNull(message = "사물함 운영 회차를 입력해 주세요.") | ||
| Long lockerPeriodId | ||
| ) { | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<Photo> photos, | ||
| List<LockerItem> lockers | ||
| ) { | ||
|
|
||
| /** | ||
| * @param photosById 구역 사진 파일. 삭제된 파일은 키가 없어 목록에서 조용히 빠진다 | ||
| * @param appliedLockerIds 해당 운영 회차에 이미 신청된 사물함 식별자 | ||
| */ | ||
| public static AdminLockerSectionDetailResponse of( | ||
| LockerSectionDetail detail, | ||
| Map<Long, File> photosById, | ||
| Set<Long> 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<Photo> toPhotos(LockerSectionDetail detail, Map<Long, File> 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( | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
| 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) { | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
| 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) { | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
public record LockerItem(Long lockerId, int lockerNumber, String lockerLabel, LockerStatus lockerStatus, boolean assigned)그리고 #148에서는 이용 중인 칸을 |
||
| if (applied) { | ||
| return ASSIGNED; | ||
| } | ||
| return status == LockerStatus.AVAILABLE ? AVAILABLE : DISABLED; | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
if (applied) {
return ASSIGNED;
}
return locker.isSelectable(false) ? AVAILABLE : DISABLED; |
||
| } | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<Section> sections) { | ||
|
|
||
| public static AdminLockerSectionListResponse from(List<LockerSectionSummary> 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() | ||
| ); | ||
| } | ||
| } | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[a]구역을 식별자 오름차순으로 둔 것도 좋아 보여요. 학생 목록(findAllByOrderByIdAsc)과 순서가 같고, 라벨로 정렬하면A-10이A-2보다 앞에 오는 문제가 생기니까요. 나중에 운영진이 순서를 바꿔야 하면 그때display_order를 두면 될 것 같아요.