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

/** 구역별 전체·선택 가능·배정 사물함 수. */
Expand Down Expand Up @@ -47,4 +48,22 @@ ApiResponse<AdminLockerSectionDetailResponse> getLockerSection(
Long sectionId,
@ParameterObject LockerPeriodParams params
);

/** 구역 안의 사물함 여러 개를 같은 상태로 바꾼다. */
@Operation(summary = "사물함 상태 일괄 변경",
description = "구역 안의 사물함 여러 개를 AVAILABLE(선택가능) 또는 DISABLED(선택불가)로 한 번에 바꾼다. "
Comment thread
sangrae2325 marked this conversation as resolved.
+ "하나라도 바꿀 수 없으면 아무것도 바뀌지 않는다. "
+ "사용 기간이 끝나지 않은 회차에 배정된 사물함은 이용 중인 학생이 있으므로 DISABLED로 바꿀 수 없고, "
+ "AVAILABLE로 되돌리는 것은 막지 않는다. "
+ "구역 상세가 내려주는 ASSIGNED는 신청으로 생기는 표시용 값이라 이 경로로 바꿀 수 없다. "
+ "응답 본문이 없으므로 바뀐 결과는 구역 상세를 다시 조회해 받는다. "
+ "이용 중 검사와 학생 신청이 겹치면 서로 다른 테이블에 써서 둘 다 성공할 수 있고, "
+ "그때는 배정된 칸이 사용 중지된 채로 남는다.")
@ApiErrorCode(type = CommonErrorCode.class, codes = {"INVALID_INPUT"})
@ApiErrorCode(type = LockerErrorCode.class, codes = {"LOCKER_NOT_IN_SECTION", "LOCKER_IN_USE"})
ApiResponse<Void> changeLockerStatus(
AdminApiUser apiUser,
Long sectionId,
LockerStatusChangeRequest request
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,18 +6,22 @@
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.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.event.domain.locker.domain.LockerSectionDetail;
import kr.ac.kookmin.stream.event.domain.locker.domain.LockerSectionSummary;
import kr.ac.kookmin.stream.event.domain.locker.service.AdminLockerService;
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.PatchMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

Expand All @@ -27,6 +31,7 @@
public class AdminLockerSectionController implements AdminLockerSectionApi {

private final LockerService lockerService;
private final AdminLockerService adminLockerService;
private final FileService fileService;

@Override
Expand Down Expand Up @@ -54,4 +59,15 @@ public ApiResponse<AdminLockerSectionDetailResponse> getLockerSection(
return ApiResponse.success(
AdminLockerSectionDetailResponse.of(detail, photosById, appliedLockerIds));
}

@Override
@PatchMapping("/{sectionId}/lockers/status")
public ApiResponse<Void> changeLockerStatus(
AdminApiUser apiUser,
@PathVariable Long sectionId,
@Valid @RequestBody LockerStatusChangeRequest request
) {
adminLockerService.changeLockerStatus(sectionId, request.distinctLockerIds(), request.lockerStatus());
return ApiResponse.success();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
package kr.ac.kookmin.stream.api.admin.event.locker.request;

import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.NotNull;
import java.util.List;
import kr.ac.kookmin.stream.event.domain.locker.domain.LockerStatus;

/**
* 구역 안의 사물함 여러 개를 같은 상태로 바꾼다.
* <p>
* 구역 상세가 내려주는 {@code ASSIGNED}는 신청으로 생기는 표시용 값이라 이 경로로는 바꿀 수 없다.
*/
public record LockerStatusChangeRequest(
@NotEmpty(message = "사물함을 하나 이상 선택해 주세요.")
List<@NotNull(message = "사물함 ID는 비워 둘 수 없습니다.") Long> lockerIds,
Comment thread
sangrae2325 marked this conversation as resolved.

@NotNull(message = "변경할 상태를 입력해 주세요.")
LockerStatus lockerStatus
) {

/**
* 중복을 걷어낸 사물함 식별자. 같은 칸이 여러 번 와도 결과는 같지만, 그대로 두면 이용 중 검사의 {@code IN} 절이
* 구역의 칸 수보다 커진다. 중복을 없애면 상한이 그 구역의 칸 수가 되어 따로 숫자를 둘 필요가 없다.
*/
public List<Long> distinctLockerIds() {
return lockerIds.stream().distinct().toList();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,13 @@ public static Locker of(
* @param applied 해당 운영 회차에 이 사물함이 이미 신청되었는지
*/
public boolean isSelectable(boolean applied) {
return status == LockerStatus.AVAILABLE && !applied;
return status.isUsable() && !applied;
}

/**
* 사물함 자체 상태를 바꾼다. 이미 그 상태여도 그대로 둔다 — 영속성 컨텍스트가 값이 바뀐 행만 UPDATE한다.
*/
public void changeStatus(LockerStatus status) {
this.status = status;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ public enum LockerErrorCode implements ErrorCode {
LOCKER_PERIOD_APPLY_ALREADY_STARTED(ErrorStatus.CONFLICT, "신청이 시작된 회차는 신청 시작일을 바꿀 수 없습니다."),
LOCKER_PERIOD_HAS_APPLICATIONS(ErrorStatus.CONFLICT, "신청이 있는 회차는 삭제할 수 없습니다."),
LOCKER_APPLY_NOT_OPEN(ErrorStatus.CONFLICT, "사물함 신청 기간이 아닙니다."),
LOCKER_NOT_IN_SECTION(ErrorStatus.BAD_REQUEST, "해당 구역의 사물함이 아닙니다."),
LOCKER_IN_USE(ErrorStatus.CONFLICT, "이용 중인 사물함은 사용 중지할 수 없습니다."),
LOCKER_ALREADY_ASSIGNED(ErrorStatus.CONFLICT, "다른 사용자가 먼저 신청한 사물함입니다.");

private final int status;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,16 @@
package kr.ac.kookmin.stream.event.domain.locker.domain;

/** 사물함 자체의 물리 상태. 회차 배정 여부는 담지 않는다. */
public enum LockerStatus {

AVAILABLE,
DISABLED
DISABLED;

/**
* 학생에게 내줄 수 있는 상태인지. 쓸 수 있는 상태를 허용 목록으로 두어, 나중에 상태가 늘어도 그 값이 자동으로
* "쓸 수 없음"에 들어가게 한다. 상태를 차단 목록으로 보는 곳이 생기면 새 값만 조용히 빠져나간다.
*/
public boolean isUsable() {
return this == AVAILABLE;
}
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package kr.ac.kookmin.stream.event.domain.locker.repository;

import java.util.Collection;
import java.util.List;
import java.util.Optional;
import java.util.Set;
Expand Down Expand Up @@ -35,6 +36,11 @@ public interface LockerApplicationRepository {
*/
Optional<Long> findAppliedLockerId(Long lockerPeriodId, Long memberId);

/**
* 해당 회차들에 이 사물함 중 하나라도 신청됐는지. (회차, 사물함) 유니크 인덱스를 그대로 탄다.
*/
boolean existsByPeriodIdsAndLockerIds(Collection<Long> lockerPeriodIds, Collection<Long> lockerIds);

/**
* 회원의 사물함 신청 전체를 신청 일시 최신순으로 조회한다.
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -92,4 +92,14 @@ public interface LockerRepository {
* 식별자에 해당하는 사물함. 지난 신청 이력의 사물함 이름을 보여줘야 하므로 삭제된 사물함도 포함한다.
*/
List<Locker> findLockersByIdsIncludingDeleted(Collection<Long> lockerIds);

/**
* 식별자에 해당하는 사물함. 삭제된 사물함은 없는 것으로 본다. 순서는 보장하지 않는다.
*/
List<Locker> findLockersByIds(Collection<Long> lockerIds);

/**
* 사물함 여러 건을 저장한다. 변경은 호출한 쪽 트랜잭션이 커밋될 때 반영된다.
*/
void saveLockers(List<Locker> lockers);
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,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.LockerStatus;

/**
* 운영진 사물함 API가 쓰는 서비스.
Expand Down Expand Up @@ -36,4 +37,12 @@ public interface AdminLockerService {
* 운영 회차를 소프트 삭제한다. 신청이 한 건이라도 있으면 거부한다.
*/
void deletePeriod(Long lockerPeriodId);

/**
* 구역 안의 사물함 여러 개의 상태를 한 번에 바꾼다.
* <p>
* 하나라도 바꿀 수 없으면 아무것도 바꾸지 않는다. 사용 기간이 끝나지 않은 회차에 배정된 사물함은
* 이용 중인 학생이 있으므로 {@code DISABLED}로 바꿀 수 없다.
*/
void changeLockerStatus(Long sectionId, List<Long> lockerIds, LockerStatus status);
}
Original file line number Diff line number Diff line change
@@ -1,13 +1,20 @@
package kr.ac.kookmin.stream.event.domain.locker.service.impl;

import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;
import kr.ac.kookmin.stream.common.BusinessException;
import kr.ac.kookmin.stream.event.domain.locker.domain.Locker;
import kr.ac.kookmin.stream.event.domain.locker.domain.LockerApplication;
import kr.ac.kookmin.stream.event.domain.locker.domain.LockerApplicationStatus;
import kr.ac.kookmin.stream.event.domain.locker.domain.LockerErrorCode;
import kr.ac.kookmin.stream.event.domain.locker.domain.LockerPeriod;
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.LockerStatus;
import kr.ac.kookmin.stream.event.domain.locker.repository.LockerApplicationRepository;
import kr.ac.kookmin.stream.event.domain.locker.repository.LockerRepository;
import kr.ac.kookmin.stream.event.domain.locker.service.AdminLockerService;
Expand Down Expand Up @@ -66,6 +73,59 @@ public void deletePeriod(Long lockerPeriodId) {
}
}

@Override
@Transactional
public void changeLockerStatus(Long sectionId, List<Long> lockerIds, LockerStatus status) {
List<Locker> lockers = getLockersInSection(sectionId, lockerIds);
if (!status.isUsable()) {
validateNotInUse(lockerIds);
}

lockers.forEach(locker -> locker.changeStatus(status));
lockerRepository.saveLockers(lockers);
}

/**
* 요청한 사물함을 구역 소속으로 확인하고 돌려준다. 하나라도 그 구역에 없으면 아무것도 바꾸지 않도록 여기서 끊는다.
*/
private List<Locker> getLockersInSection(Long sectionId, List<Long> lockerIds) {
// 구역부터 확인한다. 없는 구역이면 같은 경로의 구역 상세 조회와 같이 404로 나가야 한다
if (lockerRepository.findSectionById(sectionId).isEmpty()) {
throw new BusinessException(LockerErrorCode.LOCKER_SECTION_NOT_FOUND);
}

// 구역 전체가 아니라 요청한 사물함만 읽는다. 36칸 구역에서 두 칸만 바꿔도 전체를 읽지 않도록 한다
List<Locker> lockers = lockerRepository.findLockersByIds(lockerIds);
boolean allInSection = lockers.size() == lockerIds.size()
&& lockers.stream().allMatch(locker -> locker.getSectionId().equals(sectionId));
if (!allInSection) {
// 없는 사물함과 다른 구역 사물함을 가르지 않는다. 운영 화면에서는 둘 다 "잘못 고른 칸"이다
throw new BusinessException(LockerErrorCode.LOCKER_NOT_IN_SECTION);
}
return lockers;
}

/**
* 아직 배정 상태인 신청이 걸린 사물함이 하나라도 있으면 끊는다.
* <p>
* 배정인지 만료인지는 내 신청 내역 화면과 같은 기준을 써야 하므로 {@link LockerApplicationStatus}에 맡긴다.
* 저장소는 판정에 끼지 않고 회차를 읽고 신청 존재만 알려준다.
*/
private void validateNotInUse(List<Long> lockerIds) {
LocalDate today = LocalDate.now();
// 회차를 먼저 좁힌다. 회차는 학기당 한두 건이라 전부 읽어도 되고, 그래야 신청 조회가 (회차, 사물함)
// 유니크 인덱스를 탄다. 사물함 식별자만으로 신청을 찾으면 탈 인덱스가 없어 이력 전체를 훑는다.
// 게시 여부로 거르지 않는다. 신청은 게시된 회차에만 들어와 지금은 차이가 없고, 거르려면 포트만 하나 더 는다
Set<Long> assignedPeriodIds = lockerRepository.findAllPeriods().stream()
.filter(period -> LockerApplicationStatus.from(period, today) == LockerApplicationStatus.ASSIGNED)
.map(LockerPeriod::getId)
.collect(Collectors.toSet());

if (lockerApplicationRepository.existsByPeriodIdsAndLockerIds(assignedPeriodIds, lockerIds)) {
throw new BusinessException(LockerErrorCode.LOCKER_IN_USE);
}
}

private LockerPeriod getPeriod(Long lockerPeriodId) {
return lockerRepository.findPeriodById(lockerPeriodId)
.orElseThrow(() -> new BusinessException(LockerErrorCode.LOCKER_PERIOD_NOT_FOUND));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,8 @@ private LockerSectionSummary summarize(
int availableCount = (int) lockers.stream()
.filter(locker -> locker.isSelectable(appliedLockerIds.contains(locker.getId())))
.count();
// 사용 중지된 사물함도 배정돼 있으면 센다. 배정 후 고장 난 칸이라 운영진이 다른 자리를 줘야 하는 대상이다
// 사용 중지된 사물함도 배정돼 있으면 센다. 상태 변경 API가 이용 중인 칸의 사용 중지를 막아 그 조합은 흔치
// 않지만, DB 직접 수정이나 검사와 신청이 겹친 경우로 남을 수 있다
int assignedCount = (int) lockers.stream()
.filter(locker -> appliedLockerIds.contains(locker.getId()))
.count();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -360,6 +360,16 @@ public List<LockerPeriod> findPublishedPeriodsUsageEndingOn(LocalDate usageEndAt
throw new UnsupportedOperationException();
}

@Override
public void saveLockers(List<Locker> lockers) {
throw new UnsupportedOperationException();
}

@Override
public List<Locker> findLockersByIds(Collection<Long> lockerIds) {
throw new UnsupportedOperationException();
}

@Override
public List<Locker> findLockersByIdsIncludingDeleted(Collection<Long> lockerIds) {
batchLookups++;
Expand Down Expand Up @@ -428,6 +438,11 @@ private static final class FakeLockerApplicationRepository implements LockerAppl
private final List<LockerApplication> saved = new ArrayList<>();
private List<LockerApplication> memberApplications = List.of();

@Override
public boolean existsByPeriodIdsAndLockerIds(Collection<Long> lockerPeriodIds, Collection<Long> lockerIds) {
throw new UnsupportedOperationException();
}

@Override
public boolean existsByPeriod(Long lockerPeriodId) {
throw new UnsupportedOperationException();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -323,6 +323,16 @@ public List<LockerPeriod> findPublishedPeriodsUsageEndingOn(LocalDate usageEndAt
throw new UnsupportedOperationException();
}

@Override
public void saveLockers(List<Locker> lockers) {
throw new UnsupportedOperationException();
}

@Override
public List<Locker> findLockersByIds(Collection<Long> lockerIds) {
throw new UnsupportedOperationException();
}

@Override
public List<Locker> findLockersByIdsIncludingDeleted(Collection<Long> lockerIds) {
throw new UnsupportedOperationException();
Expand Down Expand Up @@ -351,6 +361,11 @@ public Optional<Long> findAppliedLockerId(Long lockerPeriodId, Long memberId) {

// 아래는 사물함 신청(LockerApplicationService)용 메서드라 이 테스트에서는 쓰지 않는다

@Override
public boolean existsByPeriodIdsAndLockerIds(Collection<Long> lockerPeriodIds, Collection<Long> lockerIds) {
throw new UnsupportedOperationException();
}

@Override
public boolean existsByPeriod(Long lockerPeriodId) {
throw new UnsupportedOperationException();
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package kr.ac.kookmin.stream.db.event;

import java.util.Collection;
import java.util.List;
import java.util.Optional;
import java.util.Set;
Expand Down Expand Up @@ -30,6 +31,8 @@ public interface LockerApplicationJpaRepository extends JpaRepository<LockerAppl
""")
List<Long> findMemberIdsByLockerPeriodId(@Param("lockerPeriodId") Long lockerPeriodId);

boolean existsByLockerPeriodIdInAndLockerIdIn(Collection<Long> lockerPeriodIds, Collection<Long> lockerIds);

/**
* 회차당 회원 신청은 유니크 제약으로 한 건까지만 존재한다.
*/
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package kr.ac.kookmin.stream.db.event;

import java.util.Collection;
import java.util.List;
import java.util.Optional;
import java.util.Set;
Expand Down Expand Up @@ -45,6 +46,14 @@ public Optional<Long> findAppliedLockerId(Long lockerPeriodId, Long memberId) {
return lockerApplicationJpaRepository.findLockerIdByLockerPeriodIdAndMemberId(lockerPeriodId, memberId);
}

@Override
public boolean existsByPeriodIdsAndLockerIds(Collection<Long> lockerPeriodIds, Collection<Long> lockerIds) {
if (lockerPeriodIds.isEmpty() || lockerIds.isEmpty()) {
return false;
}
return lockerApplicationJpaRepository.existsByLockerPeriodIdInAndLockerIdIn(lockerPeriodIds, lockerIds);
}

@Override
public List<LockerApplication> findByMemberId(Long memberId) {
return lockerApplicationJpaRepository.findAllByMemberIdOrderByAppliedAtDescIdDesc(memberId).stream()
Expand Down
Loading