Skip to content
Merged
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 @@ -8,6 +8,7 @@
import kr.ac.kookmin.stream.api.app.event.locker.response.LockerApplyResponse;
import kr.ac.kookmin.stream.api.app.event.locker.response.LockerSectionDetailResponse;
import kr.ac.kookmin.stream.api.app.event.locker.response.LockerSectionListResponse;
import kr.ac.kookmin.stream.api.app.event.locker.response.MyLockerApplicationListResponse;
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;
Expand Down Expand Up @@ -53,4 +54,10 @@ ApiResponse<LockerSectionDetailResponse> getSectionLockers(
@ApiErrorCode(type = CommonErrorCode.class, codes = {"INVALID_INPUT"})
@ApiErrorCode(type = LockerErrorCode.class, codes = {"LOCKER_PERIOD_NOT_FOUND", "LOCKER_ALREADY_ASSIGNED"})
ApiResponse<LockerApplyResponse> apply(AppApiUser apiUser, LockerApplyRequest request);

/** 내 사물함 신청 내역. */
@Operation(summary = "내 배정 사물함 조회",
description = "게시된 운영 회차에서 배정된 내 사물함을 신청 일시 최신순으로 조회한다. 사용 기간이 끝난 회차는 "
+ "EXPIRED로 내려간다. 배정된 사물함이 없으면 data가 null이다.")
ApiResponse<MyLockerApplicationListResponse> getMyApplications(AppApiUser apiUser);
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package kr.ac.kookmin.stream.api.app.event.locker;

import jakarta.validation.Valid;
import java.time.LocalDate;
import java.util.List;
import java.util.Set;
import kr.ac.kookmin.stream.api.app.AppApiUser;
Expand All @@ -9,8 +10,10 @@
import kr.ac.kookmin.stream.api.app.event.locker.response.LockerApplyResponse;
import kr.ac.kookmin.stream.api.app.event.locker.response.LockerSectionDetailResponse;
import kr.ac.kookmin.stream.api.app.event.locker.response.LockerSectionListResponse;
import kr.ac.kookmin.stream.api.app.event.locker.response.MyLockerApplicationListResponse;
import kr.ac.kookmin.stream.api.common.dto.ApiResponse;
import kr.ac.kookmin.stream.event.domain.locker.domain.Locker;
import kr.ac.kookmin.stream.event.domain.locker.domain.LockerApplicationResult;
import kr.ac.kookmin.stream.event.domain.locker.domain.LockerSectionSummary;
import kr.ac.kookmin.stream.event.domain.locker.service.LockerApplicationService;
import kr.ac.kookmin.stream.event.domain.locker.service.LockerService;
Expand Down Expand Up @@ -79,4 +82,15 @@ public ApiResponse<LockerApplyResponse> apply(
LockerApplyResponse.from(lockerApplicationService.apply(apiUser.userId(), request.toCommand()))
);
}

@Override
@GetMapping("/applications")
public ApiResponse<MyLockerApplicationListResponse> getMyApplications(AppApiUser apiUser) {
List<LockerApplicationResult> applications =
lockerApplicationService.getApplicationsByMemberId(apiUser.userId());
// 배정된 사물함이 없으면 빈 목록 대신 data를 비운다 (명세)
return ApiResponse.success(
applications.isEmpty() ? null : MyLockerApplicationListResponse.of(applications, LocalDate.now())
);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
package kr.ac.kookmin.stream.api.app.event.locker.response;

import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.List;
import kr.ac.kookmin.stream.event.domain.locker.domain.LockerApplicationResult;
import kr.ac.kookmin.stream.event.domain.locker.domain.LockerApplicationStatus;
import kr.ac.kookmin.stream.event.domain.locker.domain.LockerPeriod;

public record MyLockerApplicationListResponse(List<Item> applications) {

/**
* @param today 배정 상태(배정완료·이용종료)를 판정할 기준일
*/
public static MyLockerApplicationListResponse of(List<LockerApplicationResult> applications, LocalDate today) {
return new MyLockerApplicationListResponse(applications.stream()
.map(application -> Item.of(application, today))
.toList());
}

public record Item(
Long lockerApplicationId,
Long lockerPeriodId,
String lockerPeriodName,
LockerApplicationStatus applicationStatus,
LocalDateTime appliedAt,
LocalDate usageStartDate,
LocalDate usageEndDate,
String lockerLabel
) {

public static Item of(LockerApplicationResult application, LocalDate today) {
LockerPeriod period = application.period();
return new Item(
application.application().getId(),
period.getId(),
period.getName(),
LockerApplicationStatus.from(period, today),
application.application().getAppliedAt(),
period.getUsageStartAt(),
period.getUsageEndAt(),
application.locker().getLockerLabel()
);
}
}
}
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
package kr.ac.kookmin.stream.event.domain.locker.domain;

/**
* 사물함 신청 결과. 생성된 신청과 배정된 사물함, 사용 기간을 알려줄 운영 회차를 함께 돌려준다.
* 사물함 신청 한 건과 배정된 사물함, 사용 기간을 알려줄 운영 회차의 묶음. 신청 직후 결과와 회원별 신청 내역에 함께 쓴다.
* <p>
* 필드를 복사하지 않고 도메인 객체를 그대로 담는다. 응답에 필요한 값은 표현 계층이 꺼내 쓴다.
*/
public record LockerApplicationResult(LockerApplication application, Locker locker, LockerPeriod period) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package kr.ac.kookmin.stream.event.domain.locker.domain;

import java.time.LocalDate;

/**
* 내 사물함 신청의 배정 상태. 신청이 곧 배정이라 신청 대기 상태는 없고, 사용 기간이 끝났는지로만 가른다.
*/
public enum LockerApplicationStatus {

ASSIGNED,
EXPIRED;

public static LockerApplicationStatus from(LockerPeriod period, LocalDate today) {
return period.isUsageEnded(today) ? EXPIRED : ASSIGNED;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -32,4 +32,11 @@ public static LockerPeriod of(
) {
return new LockerPeriod(id, name, applyStartAt, applyEndAt, usageStartAt, usageEndAt, published);
}

/**
* 사용 기간이 끝났는지. 사용 종료일 당일까지는 사용 중으로 본다.
*/
public boolean isUsageEnded(LocalDate today) {
return today.isAfter(usageEndAt);
}
}
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.List;
import java.util.Optional;
import java.util.Set;
import kr.ac.kookmin.stream.event.domain.locker.domain.LockerApplication;
Expand All @@ -24,6 +25,11 @@ public interface LockerApplicationRepository {
*/
Optional<Long> findAppliedLockerId(Long lockerPeriodId, Long memberId);

/**
* 회원의 사물함 신청 전체를 신청 일시 최신순으로 조회한다.
*/
List<LockerApplication> findByMemberId(Long memberId);

/**
* 신청을 저장한다. 같은 회차의 같은 사물함에 먼저 저장된 신청이 있으면
* {@link kr.ac.kookmin.stream.event.domain.locker.domain.LockerErrorCode#LOCKER_ALREADY_ASSIGNED}로 실패한다.
Expand Down
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 kr.ac.kookmin.stream.event.domain.locker.domain.Locker;
Expand All @@ -23,6 +24,11 @@ public interface LockerRepository {
*/
Optional<LockerPeriod> findPublishedPeriodById(Long lockerPeriodId);

/**
* 게시된 운영 회차 중 식별자에 해당하는 것. 순서는 보장하지 않는다.
*/
List<LockerPeriod> findPublishedPeriodsByIds(Collection<Long> lockerPeriodIds);

/**
* 전체 구역을 식별자 오름차순으로 조회한다.
*/
Expand All @@ -42,4 +48,9 @@ public interface LockerRepository {
* 사물함 한 건. 삭제된 사물함은 없는 것으로 본다.
*/
Optional<Locker> findLockerById(Long lockerId);

/**
* 식별자에 해당하는 사물함. 지난 신청 이력의 사물함 이름을 보여줘야 하므로 삭제된 사물함도 포함한다.
*/
List<Locker> findLockersByIdsIncludingDeleted(Collection<Long> lockerIds);
}
Original file line number Diff line number Diff line change
@@ -1,13 +1,19 @@
package kr.ac.kookmin.stream.event.domain.locker.service;

import java.util.List;
import kr.ac.kookmin.stream.event.domain.locker.domain.LockerApplicationResult;
import kr.ac.kookmin.stream.event.domain.locker.domain.LockerApplyCommand;

/** 사물함 신청. 구역·배치 조회는 {@link LockerService}가 맡는다. */
/** 사물함 신청과 그 이력. 구역·배치 조회는 {@link LockerService}가 맡는다. */
public interface LockerApplicationService {

/**
* 고른 사물함을 신청하고 즉시 배정한다. 같은 사물함에 동시에 신청하면 먼저 저장된 신청만 성공한다.
*/
LockerApplicationResult apply(Long memberId, LockerApplyCommand command);

/**
* 게시된 운영 회차에서 회원이 신청한 사물함 내역을 신청 일시 최신순으로 조회한다. 없으면 빈 목록이다.
*/
List<LockerApplicationResult> getApplicationsByMemberId(Long memberId);
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
package kr.ac.kookmin.stream.event.domain.locker.service.impl;

import java.time.LocalDateTime;
import java.util.List;
import java.util.Map;
import java.util.function.Function;
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;
Expand Down Expand Up @@ -34,6 +38,54 @@ public LockerApplicationResult apply(Long memberId, LockerApplyCommand command)
return new LockerApplicationResult(application, locker, period);
}

/**
* 트랜잭션을 걸지 않는다. 신청은 취소·변경이 없고, 조회 사이에 회차 게시·이름이 바뀌어도 각 건은 그 시점에 맞는
* 결과라 세 조회가 같은 시점을 볼 필요가 없다. 한 스냅샷이 필요한 조회가 추가되면 다시 판단한다.
*/
@Override
public List<LockerApplicationResult> getApplicationsByMemberId(Long memberId) {
List<LockerApplication> applications = lockerApplicationRepository.findByMemberId(memberId);
if (applications.isEmpty()) {
return List.of();
}

Map<Long, LockerPeriod> publishedPeriods = getPublishedPeriods(applications);
Map<Long, Locker> assignedLockers = getAssignedLockersIncludingRemoved(applications);

return applications.stream()
// 게시를 내린 회차의 신청은 학생에게 없는 것으로 보여야 한다
.filter(application -> publishedPeriods.containsKey(application.getLockerPeriodId()))
.map(application -> new LockerApplicationResult(
application,
assignedLockers.get(application.getLockerId()),
publishedPeriods.get(application.getLockerPeriodId())
))
.toList();
}

/** 신청들이 속한 운영 회차 중 게시된 것. 식별자로 찾아 쓸 수 있게 묶는다. */
private Map<Long, LockerPeriod> getPublishedPeriods(List<LockerApplication> applications) {
List<Long> lockerPeriodIds = applications.stream()
.map(LockerApplication::getLockerPeriodId)
.distinct()
.toList();
return lockerRepository.findPublishedPeriodsByIds(lockerPeriodIds).stream()
.collect(Collectors.toMap(LockerPeriod::getId, Function.identity()));
}

/**
* 신청들에 배정된 사물함. 이용이 끝난 뒤 철거(삭제)된 사물함이어도 지난 신청의 사물함 이름을 보여줘야 하므로
* 삭제된 사물함도 포함한다.
*/
private Map<Long, Locker> getAssignedLockersIncludingRemoved(List<LockerApplication> applications) {
List<Long> lockerIds = applications.stream()
.map(LockerApplication::getLockerId)
.distinct()
.toList();
return lockerRepository.findLockersByIdsIncludingDeleted(lockerIds).stream()
.collect(Collectors.toMap(Locker::getId, Function.identity()));
}

/** 게시된 운영 회차. 아직 공개하지 않은 회차는 학생에게 없는 것으로 보여야 하므로 구역 조회와 같은 기준으로 거른다. */
private LockerPeriod getPublishedPeriod(Long lockerPeriodId) {
return lockerRepository.findPublishedPeriodById(lockerPeriodId)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
package kr.ac.kookmin.stream.event.domain.locker.domain;

import static org.junit.jupiter.api.Assertions.assertEquals;

import java.time.LocalDate;
import java.time.LocalDateTime;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;

class LockerApplicationStatusTest {

private static final LockerPeriod PERIOD = LockerPeriod.of(
1L,
"2026-2학기",
LocalDateTime.of(2026, 8, 20, 10, 0),
LocalDateTime.of(2026, 8, 25, 18, 0),
LocalDate.of(2026, 9, 7),
LocalDate.of(2026, 12, 15),
true
);

@ParameterizedTest
@DisplayName("사용 종료일 당일까지는 배정 상태이고, 다음 날부터 이용 종료다")
@CsvSource({
"2026-09-01, ASSIGNED", // 사용 시작 전
"2026-09-07, ASSIGNED", // 사용 시작일
"2026-12-15, ASSIGNED", // 사용 종료일 당일
"2026-12-16, EXPIRED", // 사용 종료 다음 날
"2027-03-02, EXPIRED"
})
void byUsageEndDate(LocalDate today, LockerApplicationStatus expected) {
assertEquals(expected, LockerApplicationStatus.from(PERIOD, today));
}
}
Loading