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
@@ -0,0 +1,46 @@
package kr.ac.kookmin.stream.api.app.event.locker;

import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import kr.ac.kookmin.stream.api.app.AppApiUser;
import kr.ac.kookmin.stream.api.app.event.locker.request.LockerPeriodParams;
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.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 AppLockerController}가 맡는다.
* <p>
* 스웨거 문서용 어노테이션만 이쪽에 두고 컨트롤러에는 라우팅과 본문만 남긴다. 경로 매핑과
* 파라미터 바인딩(@{@code ModelAttribute}, @{@code PathVariable} 등)은 구현체에 둔다.
*/
@Tag(name = "사물함", description = "학생 앱 사물함 구역·배치 조회")
public interface AppLockerApi {

/** 구역별 전체·선택 가능 사물함 수와 표시 상태. */
@Operation(summary = "사물함 구역 목록 조회",
description = "운영 회차의 구역별 전체 사물함 수와 현재 선택 가능한 사물함 수를 조회한다. "
+ "사용 중지된 사물함과 해당 회차에 이미 신청된 사물함은 선택 가능 수에서 빠진다.")
@ApiErrorCode(type = CommonErrorCode.class, codes = {"INVALID_INPUT"})
@ApiErrorCode(type = LockerErrorCode.class, codes = {"LOCKER_PERIOD_NOT_FOUND"})
ApiResponse<LockerSectionListResponse> getSections(
AppApiUser apiUser,
@ParameterObject LockerPeriodParams params
);

/** 구역에 속한 사물함의 배치 정보와 선택 가능 여부. */
@Operation(summary = "사물함 구역 상세 조회",
description = "구역에 속한 사물함의 배치도 위치와 선택 가능 여부를 조회한다. "
+ "사물함이 사용 가능한 상태이고 해당 회차에 신청되지 않은 경우에만 선택할 수 있다.")
@ApiErrorCode(type = CommonErrorCode.class, codes = {"INVALID_INPUT"})
@ApiErrorCode(type = LockerErrorCode.class, codes = {"LOCKER_PERIOD_NOT_FOUND", "LOCKER_SECTION_NOT_FOUND"})
ApiResponse<LockerSectionDetailResponse> getSectionLockers(
AppApiUser apiUser,
Long sectionId,
@ParameterObject LockerPeriodParams params
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
package kr.ac.kookmin.stream.api.app.event.locker;

import jakarta.validation.Valid;
import java.util.List;
import java.util.Set;
import kr.ac.kookmin.stream.api.app.AppApiUser;
import kr.ac.kookmin.stream.api.app.event.locker.request.LockerPeriodParams;
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.common.dto.ApiResponse;
import kr.ac.kookmin.stream.event.domain.locker.domain.Locker;
import kr.ac.kookmin.stream.event.domain.locker.domain.LockerSectionSummary;
import kr.ac.kookmin.stream.event.domain.locker.service.LockerService;
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;

/**
* 학생 앱의 사물함 구역·배치 조회 API.
* <p>
* 선택 가능 여부와 내 사물함 표시는 조회 결과를 응답 DTO에서 맞춰봐서 만든다. 미게시 회차·없는 구역
* 판정은 구역 조회가 하므로 신청 조회보다 먼저 호출해야 404가 앞선다.
*/
@RestController
@RequestMapping("/v1/app/lockers")
@RequiredArgsConstructor
public class AppLockerController implements AppLockerApi {

private final LockerService lockerService;

@Override
@GetMapping("/sections")
public ApiResponse<LockerSectionListResponse> getSections(
AppApiUser apiUser,
@Valid @ModelAttribute LockerPeriodParams params
) {
Long lockerPeriodId = params.lockerPeriodId();
List<LockerSectionSummary> sections = lockerService.getSections(lockerPeriodId);
Long mySectionId = lockerService.getLockerByMemberId(lockerPeriodId, apiUser.userId())
.map(Locker::getSectionId)
.orElse(null);

return ApiResponse.success(LockerSectionListResponse.of(sections, mySectionId));
}

@Override
@GetMapping("/sections/{sectionId}")
public ApiResponse<LockerSectionDetailResponse> getSectionLockers(
AppApiUser apiUser,
@PathVariable Long sectionId,
@Valid @ModelAttribute LockerPeriodParams params
) {
Long lockerPeriodId = params.lockerPeriodId();
List<Locker> lockers = lockerService.getSectionLockers(lockerPeriodId, sectionId);
Set<Long> appliedLockerIds = lockerService.getAppliedLockerIds(lockerPeriodId);
Long myLockerId = lockerService.getLockerByMemberId(lockerPeriodId, apiUser.userId())
.map(Locker::getId)
.orElse(null);

return ApiResponse.success(LockerSectionDetailResponse.of(lockers, appliedLockerIds, myLockerId));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package kr.ac.kookmin.stream.api.app.event.locker.request;

import jakarta.validation.constraints.NotNull;

/**
* @param lockerPeriodId 조회할 사물함 운영 회차 식별자
*/
public record LockerPeriodParams(

@NotNull(message = "사물함 운영 회차를 입력해 주세요.")
Long lockerPeriodId
) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
package kr.ac.kookmin.stream.api.app.event.locker.response;

import java.util.Objects;
import kr.ac.kookmin.stream.event.domain.locker.domain.Locker;

public record LockerResponse(
Long lockerId,
String lockerLabel,
int lockerNumber,
int rowNo,
int columnNo,
boolean isAvailable,
boolean isMine
) {

/**
* @param applied 해당 운영 회차에 이 사물함이 이미 신청되었는지
* @param myLockerId 조회한 회원이 신청한 사물함. 신청하지 않았으면 {@code null}
*/
public static LockerResponse of(Locker locker, boolean applied, Long myLockerId) {
return new LockerResponse(
locker.getId(),
locker.getLockerLabel(),
locker.getLockerNumber(),
locker.getRowNo(),
locker.getColumnNo(),
locker.isSelectable(applied),
Objects.equals(locker.getId(), myLockerId)
);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
package kr.ac.kookmin.stream.api.app.event.locker.response;

import java.util.List;
import java.util.Set;
import kr.ac.kookmin.stream.event.domain.locker.domain.Locker;

public record LockerSectionDetailResponse(List<LockerResponse> lockers) {

/**
* @param appliedLockerIds 해당 운영 회차에 이미 신청된 사물함 식별자
* @param myLockerId 조회한 회원이 신청한 사물함. 신청하지 않았으면 {@code null}
*/
public static LockerSectionDetailResponse of(
List<Locker> lockers,
Set<Long> appliedLockerIds,
Long myLockerId
) {
return new LockerSectionDetailResponse(lockers.stream()
.map(locker -> LockerResponse.of(locker, appliedLockerIds.contains(locker.getId()), myLockerId))
.toList());
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
package kr.ac.kookmin.stream.api.app.event.locker.response;

import java.util.Objects;
import kr.ac.kookmin.stream.event.domain.locker.domain.LockerSectionSummary;
import kr.ac.kookmin.stream.event.domain.locker.domain.SectionAvailabilityStatus;

public record LockerSectionListItemResponse(
Long sectionId,
String section,
int availableCount,
int totalCount,
SectionAvailabilityStatus availabilityStatus,
boolean hasMine
) {

/**
* @param mySectionId 조회한 회원이 신청한 사물함이 속한 구역. 신청하지 않았으면 {@code null}
*/
public static LockerSectionListItemResponse of(LockerSectionSummary summary, Long mySectionId) {
return new LockerSectionListItemResponse(
summary.sectionId(),
summary.label(),
summary.availableCount(),
summary.totalCount(),
summary.availabilityStatus(),
Objects.equals(summary.sectionId(), mySectionId)
);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package kr.ac.kookmin.stream.api.app.event.locker.response;

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

public record LockerSectionListResponse(List<LockerSectionListItemResponse> sections) {

/**
* @param mySectionId 조회한 회원이 신청한 사물함이 속한 구역. 신청하지 않았으면 {@code null}
*/
public static LockerSectionListResponse of(List<LockerSectionSummary> sections, Long mySectionId) {
return new LockerSectionListResponse(sections.stream()
.map(summary -> LockerSectionListItemResponse.of(summary, mySectionId))
.toList());
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -11,20 +11,35 @@
public class Locker {

private Long id;
private String lockerNumber;
private String section;
private Long sectionId;
/** 화면에 표시할 사물함 이름. "A-37" 형태로 관리자가 직접 입력하며 번호에서 유도하지 않는다. */
private String lockerLabel;
/** 블록 안에서 이어지는 사물함 순번. A-1 구역이 38번까지면 A-2 구역은 39번부터 시작한다. */
private int lockerNumber;
private int rowNo;
private int columnNo;
private LockerStatus status;

public static Locker of(
Long id,
String lockerNumber,
String section,
Long sectionId,
String lockerLabel,
int lockerNumber,
int rowNo,
int columnNo,
LockerStatus status
) {
return new Locker(id, lockerNumber, section, rowNo, columnNo, status);
return new Locker(id, sectionId, lockerLabel, lockerNumber, rowNo, columnNo, status);
}

/**
* 해당 운영 회차에서 선택할 수 있는지. 사물함 자체 상태와 신청 여부를 함께 본다.
* <p>
* 구역 목록의 선택 가능 수와 구역 상세의 선택 가능 여부가 같은 기준을 써야 하므로 도메인에 둔다.
*
* @param applied 해당 운영 회차에 이 사물함이 이미 신청되었는지
*/
public boolean isSelectable(boolean applied) {
return status == LockerStatus.AVAILABLE && !applied;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
package kr.ac.kookmin.stream.event.domain.locker.domain;

import kr.ac.kookmin.stream.common.ErrorCode;
import kr.ac.kookmin.stream.common.ErrorStatus;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.experimental.Accessors;

@Getter
@Accessors(fluent = true)
@AllArgsConstructor
public enum LockerErrorCode implements ErrorCode {

LOCKER_PERIOD_NOT_FOUND(ErrorStatus.NOT_FOUND, "사물함 운영 회차를 찾을 수 없습니다."),
LOCKER_SECTION_NOT_FOUND(ErrorStatus.NOT_FOUND, "사물함 구역을 찾을 수 없습니다.");

private final int status;
private final String message;
}
Original file line number Diff line number Diff line change
Expand Up @@ -18,15 +18,18 @@ public class LockerPeriod {
private LocalDateTime applyEndAt;
private LocalDate usageStartAt;
private LocalDate usageEndAt;
/** 운영진이 학생에게 공개했는지. 미게시 회차는 학생에게 없는 것으로 보여야 한다. */
private boolean published;

public static LockerPeriod of(
Long id,
String name,
LocalDateTime applyStartAt,
LocalDateTime applyEndAt,
LocalDate usageStartAt,
LocalDate usageEndAt
LocalDate usageEndAt,
boolean published
) {
return new LockerPeriod(id, name, applyStartAt, applyEndAt, usageStartAt, usageEndAt);
return new LockerPeriod(id, name, applyStartAt, applyEndAt, usageStartAt, usageEndAt, published);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
package kr.ac.kookmin.stream.event.domain.locker.domain;

import lombok.AccessLevel;
import lombok.AllArgsConstructor;
import lombok.EqualsAndHashCode;
import lombok.Getter;

/**
* 사물함 구역. 사물함을 묶는 단위이자 구역 목록·구역 상세 조회의 기준이다.
*/
@Getter
@EqualsAndHashCode
@AllArgsConstructor(access = AccessLevel.PRIVATE)
public class LockerSection {

private Long id;
private String label;

public static LockerSection of(Long id, String label) {
return new LockerSection(id, label);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package kr.ac.kookmin.stream.event.domain.locker.domain;

/**
* 사물함 구역 목록 한 건. 전체·선택 가능 수는 저장값이 아니라 조회 시점에 센다.
*/
public record LockerSectionSummary(
Long sectionId,
String label,
int availableCount,
int totalCount,
SectionAvailabilityStatus availabilityStatus
) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
package kr.ac.kookmin.stream.event.domain.locker.domain;

/**
* 구역에 남은 사물함 수로 정해지는 표시 상태.
* <p>
* 판정에 구역의 속성이 쓰이지 않고 선택 가능 수와 전체 수만 필요해, 상태 타입이 자기 생성을 소유한다.
* 구역별로 임계값이 달라지면 그때 {@link LockerSection}이 임계값을 갖고 판정을 가져가는 편이 맞다.
*/
public enum SectionAvailabilityStatus {

PLENTY,
NORMAL,
ALMOST_FULL,
FULL;

private static final double PLENTY_RATE = 0.5;
private static final double NORMAL_RATE = 0.2;

/**
* @param availableCount 선택 가능한 사물함 수
* @param totalCount 구역의 전체 사물함 수(사용 중지된 사물함 포함)
*/
public static SectionAvailabilityStatus from(int availableCount, int totalCount) {

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.

이렇게 구역의 이용 가능한 상태를 응집해놓은 거 너무 좋습니다~

// 사물함이 하나도 없는 구역의 0 나누기도 여기서 함께 걸린다
if (availableCount <= 0) {
return FULL;
}

double availabilityRate = (double) availableCount / totalCount;
if (availabilityRate >= PLENTY_RATE) {
return PLENTY;
}
if (availabilityRate >= NORMAL_RATE) {
return NORMAL;
}
return ALMOST_FULL;
}
}
Loading