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
Expand Up @@ -4,13 +4,16 @@
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.request.LockerSectionLayoutUpdateRequest;
import kr.ac.kookmin.stream.api.admin.event.locker.request.LockerSectionPhotoUpdateRequest;
import kr.ac.kookmin.stream.api.admin.event.locker.request.LockerStatusChangeRequest;
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 kr.ac.kookmin.stream.file.domain.FileErrorCode;
import org.springdoc.core.annotations.ParameterObject;

/**
Expand All @@ -20,7 +23,7 @@
* <p>
* 구역은 운영 회차에 묶여 있지 않다. 요청의 회차는 "어느 회차의 배정 현황을 볼지"를 정하는 기준이다.
*/
@Tag(name = "사물함 구역 관리", description = "운영진 사물함 구역 목록·상세 조회와 사물함 상태 변경")
@Tag(name = "사물함 구역 관리", description = "운영진 사물함 구역 목록·상세 조회, 사물함 상태 변경, 칸 배치·사진 수정")
public interface AdminLockerSectionApi {

/** 구역별 전체·선택 가능·배정 사물함 수. */
Expand Down Expand Up @@ -66,4 +69,32 @@ ApiResponse<Void> changeLockerStatus(
Long sectionId,
LockerStatusChangeRequest request
);

@Operation(summary = "사물함 구역 칸 배치 수정",
description = "구역의 칸 배치 구조(layout)를 바꾼다. 배치를 아직 등록하지 않은 구역이면 이 요청이 첫 등록이다. "
+ "보낸 JSON은 해석하지 않고 그대로 저장하며, 칸 번호가 구역의 사물함과 맞는지도 보지 않는다 — "
+ "칸 번호가 어긋나면 없는 칸이 그려지거나 있는 사물함이 화면에서 빠진다. "
+ "배치 형식 버전은 받지 않고 서버가 박는다. "
+ "응답 본문이 없으므로 바뀐 결과는 구역 상세를 다시 조회해 받는다.")
@ApiErrorCode(type = CommonErrorCode.class, codes = {"INVALID_INPUT"})
@ApiErrorCode(type = LockerErrorCode.class, codes = {"LOCKER_SECTION_NOT_FOUND"})
ApiResponse<Void> changeSectionLayout(
AdminApiUser apiUser,
Long sectionId,
LockerSectionLayoutUpdateRequest request
);

@Operation(summary = "사물함 구역 사진 수정",
description = "구역 사진을 보낸 목록으로 통째로 바꾼다. 배열 순서가 노출 순서이고, 한 장 이상이어야 한다. "
+ "지울 사진은 목록에서 빼고 남길 사진까지 함께 보낸다 — 빠진 사진은 연결만 끊기고 파일 자체는 지워지지 않는다. "
+ "사진은 칸 배치와 같은 행에 저장되므로 배치를 등록하지 않은 구역에는 사진만 올릴 수 없다. "
+ "응답 본문이 없으므로 바뀐 결과는 구역 상세를 다시 조회해 받는다.")
@ApiErrorCode(type = CommonErrorCode.class, codes = {"INVALID_INPUT"})
@ApiErrorCode(type = LockerErrorCode.class, codes = {"LOCKER_SECTION_NOT_FOUND", "LOCKER_SECTION_LAYOUT_NOT_FOUND"})
@ApiErrorCode(type = FileErrorCode.class, codes = {"FILE_NOT_FOUND"})
ApiResponse<Void> changeSectionPhotos(
AdminApiUser apiUser,
Long sectionId,
LockerSectionPhotoUpdateRequest request
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,12 @@
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.request.LockerSectionLayoutUpdateRequest;
import kr.ac.kookmin.stream.api.admin.event.locker.request.LockerSectionPhotoUpdateRequest;
import kr.ac.kookmin.stream.api.admin.event.locker.request.LockerStatusChangeRequest;
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.admin.event.locker.usecase.AdminLockerPhotoUpdateUseCase;
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;
Expand All @@ -21,6 +24,7 @@
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.PatchMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
Expand All @@ -33,6 +37,7 @@ public class AdminLockerSectionController implements AdminLockerSectionApi {
private final LockerService lockerService;
private final AdminLockerService adminLockerService;
private final FileService fileService;
private final AdminLockerPhotoUpdateUseCase adminLockerPhotoUpdateUseCase;

@Override
@GetMapping
Expand Down Expand Up @@ -70,4 +75,26 @@ public ApiResponse<Void> changeLockerStatus(
adminLockerService.changeLockerStatus(sectionId, request.distinctLockerIds(), request.lockerStatus());
return ApiResponse.success();
}

@Override
@PutMapping("/{sectionId}/layout")
public ApiResponse<Void> changeSectionLayout(
AdminApiUser apiUser,
@PathVariable Long sectionId,
@Valid @RequestBody LockerSectionLayoutUpdateRequest request
) {
adminLockerService.changeSectionLayout(sectionId, request.layout());
return ApiResponse.success();
}

@Override
@PutMapping("/{sectionId}/photos")
public ApiResponse<Void> changeSectionPhotos(
AdminApiUser apiUser,
@PathVariable Long sectionId,
@Valid @RequestBody LockerSectionPhotoUpdateRequest request
) {
adminLockerPhotoUpdateUseCase.changePhotos(sectionId, request.distinctPhotoFileIds());
return ApiResponse.success();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
package kr.ac.kookmin.stream.api.admin.event.locker.request;

import com.fasterxml.jackson.annotation.JsonIgnore;
import com.fasterxml.jackson.databind.node.ObjectNode;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.AssertTrue;
import jakarta.validation.constraints.NotNull;

/**
* 구역의 칸 배치 구조를 바꾼다.
* <p>
* 배치 형식 버전은 받지 않는다. 형식 버전은 서버와 앱 사이의 약속이라 운영진이 화면에서 고를 값이 아니고,
* 잘못 올리면 앱이 멀쩡한 배치를 모르는 형식으로 보고 그리지 않는다. 저장할 때 서버가 박는다.
*/
public record LockerSectionLayoutUpdateRequest(
@NotNull(message = "칸 배치를 입력해 주세요.")
@Schema(description = "root 블록 트리. 칸 번호는 구역 상세의 lockers[].lockerNumber와 같다")
ObjectNode root
) {

/**
* 저장할 JSON 원문.
* <p>
* 블록을 들여다보지 않고 공백만 걷어낸 문자열로 바꾼다. {@code String}으로 받으면 깨진 JSON이 DB까지 내려가
* 500이 되고, 블록 타입으로 받으면 서버가 형식을 알아야 한다. {@code ObjectNode}는 Jackson이 파싱해
* 유효한 JSON 객체임만 보장하고 내용은 모른 채 넘긴다.
*/
public String layout() {
return root.toString();
}

/**
* 빈 객체를 거른다. 편집기를 비우고 저장하면 {@code {}}가 올라오는데 {@code @NotNull}은 이를 막지 못하고
* ({@code @NotEmpty}는 {@code ObjectNode}를 지원하지 않는다), 저장되면 앱이 아무것도 그리지 않는다.
* 배치 화면에는 배치를 지우는 수단이 없어 빈 배치는 운영진의 선택이 아니다.
* <p>
* 필드가 하나라도 있는지만 보므로 블록 형식은 알지 못한다. 그래서 {@code children}이 빈 블록처럼
* 비어 있지 않으면서 아무것도 그리지 않는 배치는 걸러내지 못한다.
*/
@JsonIgnore
@Schema(hidden = true)
@AssertTrue(message = "칸 배치가 비어 있습니다.")
public boolean isRootNotEmpty() {
// null은 @NotNull이 따로 알린다. 여기서 false를 내면 메시지가 둘 붙는다
return root == null || !root.isEmpty();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
package kr.ac.kookmin.stream.api.admin.event.locker.request;

import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.NotNull;
import java.util.List;

/**
* 구역 사진을 받은 목록으로 통째로 바꾼다.
* <p>
* 화면이 사진을 지우고 추가한 뒤 저장 버튼 하나로 올리므로 개별 추가·삭제가 아니라 전체 교체다.
*/
public record LockerSectionPhotoUpdateRequest(
@NotEmpty(message = "구역 사진을 한 장 이상 등록해 주세요.")
List<@NotNull(message = "파일 ID는 비워 둘 수 없습니다.") Long> photoFileIds
) {

/**
* 중복을 걷어낸 파일 식별자. 같은 사진이 두 번 와도 한 번만 보여주면 되고, 파일 존재 확인이 개수를 견주므로
* 중복이 남아 있으면 있는 파일인데도 없다고 판단한다. 처음 나온 자리를 지켜 노출 순서는 그대로 둔다.
*/
public List<Long> distinctPhotoFileIds() {
return photoFileIds.stream().distinct().toList();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
package kr.ac.kookmin.stream.api.admin.event.locker.usecase;

import java.util.List;
import java.util.Set;
import kr.ac.kookmin.stream.common.BusinessException;
import kr.ac.kookmin.stream.event.domain.locker.service.AdminLockerService;
import kr.ac.kookmin.stream.file.domain.FileErrorCode;
import kr.ac.kookmin.stream.file.service.FileService;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;

/**
* 구역 사진을 바꾸기 전에 연결할 파일이 실제로 있는지 확인한다. 사물함(event)과 파일(file)은 서로 다른 도메인이라
* 도메인끼리 직접 의존하지 않고 이 UseCase가 두 Service를 조합한다({@code architecture.md} 6-1절).
* <p>
* 파일 확인과 사진 저장을 한 트랜잭션으로 묶지 않는다. 확인 직후 파일이 지워져도 구역 상세 조회가 삭제된 파일을
* 목록에서 조용히 제외하므로 원자성이 필요하지 않다. 공지와 같은 판단이다({@code AdminNoticeWriteUseCase}).
*/
@Component
@RequiredArgsConstructor
public class AdminLockerPhotoUpdateUseCase {

private final FileService fileService;
private final AdminLockerService adminLockerService;

public void changePhotos(Long sectionId, List<Long> photoFileIds) {
validateFilesExist(photoFileIds);
adminLockerService.changeSectionPhotos(sectionId, photoFileIds);
}

/**
* 조회된 개수를 견줘 전부 있는지 본다. 중복이 남아 있으면 있는 파일도 없다고 오판하므로 호출부에 맡기지 않고
* 여기서 묶는다.
*/
private void validateFilesExist(List<Long> photoFileIds) {
Set<Long> fileIds = Set.copyOf(photoFileIds);

if (fileService.findAllByIdIn(List.copyOf(fileIds)).size() != fileIds.size()) {
throw new BusinessException(FileErrorCode.FILE_NOT_FOUND);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ public enum LockerErrorCode implements ErrorCode {

LOCKER_PERIOD_NOT_FOUND(ErrorStatus.NOT_FOUND, "사물함 운영 회차를 찾을 수 없습니다."),
LOCKER_SECTION_NOT_FOUND(ErrorStatus.NOT_FOUND, "사물함 구역을 찾을 수 없습니다."),
LOCKER_SECTION_LAYOUT_NOT_FOUND(ErrorStatus.NOT_FOUND, "구역의 칸 배치가 등록되지 않았습니다."),
INVALID_LOCKER_PERIOD_RANGE(ErrorStatus.BAD_REQUEST, "신청 기간과 사용 기간의 순서가 올바르지 않습니다."),
LOCKER_PERIOD_SEMESTER_DUPLICATED(ErrorStatus.CONFLICT, "이미 등록된 학기입니다."),
LOCKER_PERIOD_OVERLAPPED(ErrorStatus.CONFLICT, "다른 회차와 기간이 겹칩니다."),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,13 @@
@AllArgsConstructor(access = AccessLevel.PRIVATE)
public class LockerSectionLayout {

/**
* 지금 서버가 쓰는 배치 형식 버전. 블록 종류·필드가 바뀌면 올린다.
* <p>
* 운영진이 화면에서 고르는 값이 아니다. 형식 버전은 서버와 앱 사이의 약속이라 저장할 때 서버가 박는다.
*/
public static final int CURRENT_VERSION = 1;

private Long id;
private Long sectionId;
/** root 블록 트리 JSON 원문. 서버는 해석하지 않고 그대로 내려준다. */
Expand All @@ -35,4 +42,31 @@ public static LockerSectionLayout of(
) {
return new LockerSectionLayout(id, sectionId, layout, version, photoFileIds);
}

/**
* 배치 구조를 아직 등록하지 않은 구역에 쓸 빈 행.
* <p>
* 배치 JSON과 형식 버전은 {@link #changeLayout(String)}이 함께 채운다 — 두 값을 넣는 자리를 한 곳으로 모은다.
* 사진은 따로 올리므로 빈 목록으로 시작한다.
*/
public static LockerSectionLayout createEmpty(Long sectionId) {
return new LockerSectionLayout(null, sectionId, null, 0, List.of());
}

/**
* 배치 구조를 바꾼다. 형식 버전도 지금 버전으로 맞춘다 — 저장되는 JSON은 현재 콘솔이 만든 형식이다.
*/
public void changeLayout(String layout) {
this.layout = layout;
this.version = CURRENT_VERSION;
}

/**
* 구역 사진을 받은 목록으로 통째로 바꾼다. 배열 순서가 노출 순서다.
* <p>
* 화면이 사진을 지우고 추가한 뒤 저장 버튼 하나로 올리므로 개별 추가·삭제가 아니라 전체 교체다.
*/
public void changePhotos(List<Long> photoFileIds) {
this.photoFileIds = photoFileIds;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,11 @@ public interface LockerRepository {
*/
Optional<LockerSectionLayout> findLayoutBySectionId(Long sectionId);

/**
* 구역의 배치 구조와 사진을 저장한다. 식별자가 없으면 새로 만든다.
*/
void saveLayout(LockerSectionLayout sectionLayout);

/**
* 삭제되지 않은 사물함 전체를 조회한다. 구역별 집계는 서비스가 이 목록을 묶어 센다.
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,25 @@ public interface AdminLockerService {
*/
void changeLockerStatus(Long sectionId, List<Long> lockerIds, LockerStatus status);

/**
* 구역의 칸 배치 구조를 바꾼다. 아직 배치가 없는 구역이면 새로 만든다.
* <p>
* 배치 JSON은 해석하지 않고 그대로 저장한다. 칸 번호가 구역의 사물함과 맞는지도 보지 않는다 — 블록 종류와
* 필드는 운영진 콘솔과 앱만 아는 약속이라, 서버가 대조하려면 그 형식에 묶인다. 형식 버전은 서버가 박는다.
*
* @param layout root 블록 트리 JSON 원문
*/
void changeSectionLayout(Long sectionId, String layout);

/**
* 구역 사진을 받은 목록으로 통째로 바꾼다. 배열 순서가 노출 순서다.
* <p>
* 사진은 배치와 같은 행에 있고 배치 쪽이 비어 있을 수 없어, 배치를 등록하지 않은 구역에는 사진만 올릴 수 없다.
* <p>
* 파일이 실제로 있는지는 이 서비스가 보지 않는다. 교차 도메인이라 {@code api:admin-api}의 UseCase가 확인한다.
*/
void changeSectionPhotos(Long sectionId, List<Long> photoFileIds);

/**
* 운영 회차의 신청 현황을 신청 일시 최신순으로 조회한다. 취소된 신청도 담는다.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
import kr.ac.kookmin.stream.event.domain.locker.domain.LockerPeriodCreateCommand;
import kr.ac.kookmin.stream.event.domain.locker.domain.LockerPeriodSummary;
import kr.ac.kookmin.stream.event.domain.locker.domain.LockerPeriodUpdateCommand;
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;
Expand Down Expand Up @@ -94,6 +95,32 @@ public void changeLockerStatus(Long sectionId, List<Long> lockerIds, LockerStatu
lockerRepository.saveLockers(lockers);
}

@Override
@Transactional
public void changeSectionLayout(Long sectionId, String layout) {
validateSectionExists(sectionId);

// 배치를 아직 등록하지 않은 구역이면 이 저장이 첫 등록이다
LockerSectionLayout sectionLayout = lockerRepository.findLayoutBySectionId(sectionId)
.orElseGet(() -> LockerSectionLayout.createEmpty(sectionId));
sectionLayout.changeLayout(layout);

lockerRepository.saveLayout(sectionLayout);
}

@Override
@Transactional
public void changeSectionPhotos(Long sectionId, List<Long> photoFileIds) {
validateSectionExists(sectionId);

// 사진은 배치와 같은 행에 있고 배치 쪽이 비어 있을 수 없다. 배치부터 등록해야 사진을 올릴 수 있다
LockerSectionLayout sectionLayout = lockerRepository.findLayoutBySectionId(sectionId)
.orElseThrow(() -> new BusinessException(LockerErrorCode.LOCKER_SECTION_LAYOUT_NOT_FOUND));
sectionLayout.changePhotos(photoFileIds);

lockerRepository.saveLayout(sectionLayout);
}

@Override
public PageResult<LockerApplicationResult> getApplications(
Long lockerPeriodId, List<Long> memberIds, PageOffset pageOffset
Expand Down Expand Up @@ -159,10 +186,7 @@ private void validateUsageNotEnded(Long lockerPeriodId, LocalDate today) {
* 요청한 사물함을 구역 소속으로 확인하고 돌려준다. 하나라도 그 구역에 없으면 아무것도 바꾸지 않도록 여기서 끊는다.
*/
private List<Locker> getLockersInSection(Long sectionId, List<Long> lockerIds) {
// 구역부터 확인한다. 없는 구역이면 같은 경로의 구역 상세 조회와 같이 404로 나가야 한다
if (lockerRepository.findSectionById(sectionId).isEmpty()) {
throw new BusinessException(LockerErrorCode.LOCKER_SECTION_NOT_FOUND);
}
validateSectionExists(sectionId);

// 구역 전체가 아니라 요청한 사물함만 읽는다. 36칸 구역에서 두 칸만 바꿔도 전체를 읽지 않도록 한다
List<Locker> lockers = lockerRepository.findLockersByIds(lockerIds);
Expand Down Expand Up @@ -196,6 +220,15 @@ private void validateNotInUse(List<Long> lockerIds) {
}
}

/**
* 없는 구역이면 같은 경로의 구역 상세 조회와 같이 404로 나가야 한다. 구역을 먼저 확인해 아무것도 바꾸지 않고 끊는다.
*/
private void validateSectionExists(Long sectionId) {
if (lockerRepository.findSectionById(sectionId).isEmpty()) {
throw new BusinessException(LockerErrorCode.LOCKER_SECTION_NOT_FOUND);
}
}

private LockerPeriod getPeriod(Long lockerPeriodId) {
return lockerRepository.findPeriodById(lockerPeriodId)
.orElseThrow(() -> new BusinessException(LockerErrorCode.LOCKER_PERIOD_NOT_FOUND));
Expand Down
Loading