Skip to content
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 = "운영 회차를 기준으로 구역별 전체 사물함 수, 선택 가능한 사물함 수, 배정된 사물함 수를 조회한다. "
+ "사용 중지된 사물함과 그 회차에 배정된 사물함은 선택 가능 수에서 빠진다. "
+ "배정 수는 배정된 뒤 사용 중지된 사물함도 센다. 구역은 식별자 오름차순으로 반환한다.")

Copy link
Copy Markdown
Member

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를 두면 될 것 같아요.

@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,57 @@
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;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[r] 운영진 조회가 학생 앱용 LockerService를 그대로 쓰고 있는데, 최근 coding-style.md 2-7절에 "운영진 전용 기능은 Admin{Domain}Service로 분리하고, 두 서비스는 서로 호출하지 않는다"가 들어가서 AdminLockerService 쪽으로 옮기면 좋을 것 같아요.

규칙 말고도 동작 차이가 하나 있어요. getSections·getSectionDetail은 입구에서 requirePublishedPeriod로 게시된 회차만 통과시키는데, 운영진 회차 API(#129)는 미게시 회차도 보여줘서, 운영진이 목록에서 미게시 회차를 고르면 구역 API가 LOCKER_PERIOD_NOT_FOUND를 받게 돼요. API로 만든 회차는 항상 게시 상태라 당장은 드물겠지만, V9 기본값이 is_published = 0이라 DB로 직접 넣은 회차는 미게시일 수 있어서요.

구역별 개수를 세는 로직은 LockerSectionSummary 쪽 정적 팩토리로 옮기면 두 서비스가 서로 호출하지 않고도 같이 쓸 수 있을 것 같아요. 그러면 #148에서 이 컨트롤러가 LockerService와 AdminLockerService를 함께 주입하는 것도 하나로 정리될 것 같습니다.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

공지가 search를 AdminNoticeService로 뺀 건 운영진 목록이 키워드·페이징이라 학생 커서 목록과 쿼리가 아예 다르기 때문인데, 사물함 구역은 운영진과 학생이 같은 쿼리로 같은 결과를 읽고 갈리는 건 assignedCount와 칸 표시처럼 api 모듈에서 조합하는 부분뿐이라 공용으로 두려고 합니다. 2-7절도 상세 조회는 NoticeService를 운영진이 공용으로 쓰는 예를 들고 있고, "서로 호출하지 않는다"는 서비스 간 호출이라 컨트롤러 주입은 main의 MemberService·FileService처럼 이미 쓰는 방식입니다. 미게시 회차가 안 보이는 건 남는데, 회차 등록 API가 항상 게시 상태로 만들어서 DB로 직접 넣은 회차에만 걸립니다

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.photoFileIds());
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(

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[a] 리뷰 포인트 주신 LockerPeriodParams 복제는 지금처럼 Params + @ModelAttribute로 두는 게 coding-style.md 2-2절에도 맞아서 그대로 좋을 것 같아요. GlobalExceptionHandler 보강은 별도 이슈로 가는 데 동의하고, 그때 @PathVariable Long sectionId에 abc 같은 값이 들어와 MethodArgumentTypeMismatchException으로 500이 나는 것도 같이 다루면 좋을 것 같아요.


@NotNull(message = "사물함 운영 회차를 입력해 주세요.")
Long lockerPeriodId
) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
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.of(locker, appliedLockerIds.contains(locker.getId())))
.toList()
);
}

private static List<Photo> toPhotos(LockerSectionDetail detail, Map<Long, File> photosById) {
return detail.photoFileIds().stream()
.filter(photosById::containsKey)
.map(fileId -> Photo.of(fileId, photosById.get(fileId)))
.toList();
}

/**
* @param version layout 형식 버전
* @param root 저장된 JSON을 해석하지 않고 그대로 끼워 넣는다
*/
public record Layout(

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[a] 리뷰 포인트 주신 Layout 중복은 지금처럼 복제해 두는 데 동의해요. 12줄을 공유하자고 common-api가 event 도메인에 의존하게 하는 건 손해 같고, 앱 쪽은 "모르는 버전이면 업데이트 안내"처럼 의미가 이미 갈려 있어서 나중에 쓰기 API가 생기면 더 달라질 것 같아요.

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 of(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 of(Locker locker, boolean applied) {
return new LockerItem(
locker.getId(),
locker.getLockerNumber(),
locker.getLockerLabel(),
LockerDisplayStatus.from(locker, applied)
);
}
}

/**
* 운영진 화면에 칸을 어떻게 그릴지. 저장되는 물리 상태({@link LockerStatus})와 회차 배정 여부를 합친 값이라
* 이 응답에서만 쓰고 도메인으로 올리지 않는다({@code coding-style.md} 2-2절).
*/
public enum LockerDisplayStatus {

AVAILABLE,
DISABLED,
ASSIGNED;

/**
* 배정된 뒤 사용 중지된 칸은 {@code ASSIGNED}로 본다. 운영진 화면이 칸 하나를 색 하나로 그려서 둘 중
* 하나만 고를 수 있고, 배정된 학생이 있다는 사실을 먼저 보여야 한다.
*/
public static LockerDisplayStatus from(Locker locker, boolean applied) {
if (applied) {
return ASSIGNED;
}
// 구역 목록의 availableCount와 같은 기준으로 가른다. 배정 여부는 위에서 이미 갈랐으므로 false를 넘긴다
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()
);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -40,9 +40,9 @@ ApiResponse<LockerSectionListResponse> getSections(
@Operation(summary = "사물함 구역 상세 조회",
description = "구역의 칸 배치 구조(layout)와 실제 사진, 구역에 속한 사물함의 선택 가능 여부를 조회한다. "
+ "layout의 칸 번호는 lockers[].lockerNumber와 같다. 배치 구조를 아직 등록하지 않은 구역이면 "
+ "layout과 photoUrl이 null이다. 사물함이 사용 가능한 상태이고 해당 회차에 신청되지 않은 경우에만 "
+ "선택할 수 있다. 선택 가능 여부는 모집 기간을 보지 않으므로, 모집 기간 밖에서 신청하면 "
+ "LOCKER_APPLY_NOT_OPEN이 난다.")
+ "layout이 null이고 photoUrls가 빈 배열이다. 사진은 노출 순서대로 담기며 삭제된 파일은 빠진다. "
+ "사물함이 사용 가능한 상태이고 해당 회차에 신청되지 않은 경우에만 선택할 수 있다. "
+ "선택 가능 여부는 모집 기간을 보지 않으므로, 모집 기간 밖에서 신청하면 LOCKER_APPLY_NOT_OPEN이 난다.")
@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 @@ -69,16 +69,13 @@ 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.photoFileIds());
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,57 @@
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.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) {
return detail.photoFileIds().stream()
.filter(photosById::containsKey)
.map(fileId -> StorageUrlBuilder.build(fileId, photosById))
.toList();
}

/**
* @param version layout 형식 버전. 앱은 모르는 버전이면 그리지 않고 업데이트를 안내한다
* @param root 저장된 JSON을 해석하지 않고 그대로 끼워 넣는다
Expand Down
Loading