Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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;
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(
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(

@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(
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;
}
}
}
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()
);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -39,8 +39,8 @@ ApiResponse<LockerSectionListResponse> 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<LockerSectionDetailResponse> getSectionLockers(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
import jakarta.validation.Valid;
import java.time.LocalDate;
import java.util.List;
import java.util.Optional;
import java.util.Map;
import java.util.Set;
import kr.ac.kookmin.stream.api.app.AppApiUser;
import kr.ac.kookmin.stream.api.app.event.locker.request.LockerApplyRequest;
Expand Down Expand Up @@ -70,15 +70,14 @@ public ApiResponse<LockerSectionDetailResponse> 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<Long, File> photosById = fileService.findAllByIdIn(
detail.layout() == null ? List.of() : detail.layout().getPhotoFileIds());
Set<Long> 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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,46 +3,61 @@
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;
import kr.ac.kookmin.stream.event.domain.locker.domain.LockerSectionLayout;
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<String> photoUrls,
List<LockerResponse> lockers
) {

/**
* @param photo 구역 사진 파일. 배치 구조를 아직 등록하지 않았거나 파일이 삭제됐으면 {@code null}
* @param photosById 구역 사진 파일. 삭제된 파일은 키가 없어 목록에서 조용히 빠진다
* @param appliedLockerIds 해당 운영 회차에 이미 신청된 사물함 식별자
* @param myLockerId 조회한 회원이 신청한 사물함. 신청하지 않았으면 {@code null}
*/
public static LockerSectionDetailResponse of(
LockerSectionDetail detail,
File photo,
Map<Long, File> photosById,
Set<Long> appliedLockerIds,
Long myLockerId
) {
return new LockerSectionDetailResponse(
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<String> toPhotoUrls(LockerSectionDetail detail, Map<Long, File> 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을 해석하지 않고 그대로 끼워 넣는다
Expand Down
Loading