Skip to content
Open
1 change: 1 addition & 0 deletions api/admin-api/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ description = "운영진 콘솔 — ADMIN, /v1/admin/**"
dependencies {
implementation(project(":api:common-api"))
implementation(project(":core:common"))
implementation(project(":core:domain:event"))
implementation(project(":core:domain:file"))
implementation(project(":core:domain:internal"))
implementation(project(":core:domain:member"))
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
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.LockerPeriodCreateRequest;
import kr.ac.kookmin.stream.api.admin.event.locker.request.LockerPeriodUpdateRequest;
import kr.ac.kookmin.stream.api.admin.event.locker.response.AdminLockerPeriodDetailResponse;
import kr.ac.kookmin.stream.api.admin.event.locker.response.AdminLockerPeriodListResponse;
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;

/**
* 운영진 사물함 운영 회차 API의 문서 명세. 구현은 {@link AdminLockerPeriodController}가 맡는다.
* <p>
* 접근은 {@code ADMIN} role이면 된다. 부서 제한은 두지 않았다.
* <p>
* 회차 상태(`periodStatus`)는 저장하지 않고 서버 현재 시각으로 계산한다 — 신청 시작 전이면 {@code UPCOMING},
* 사용 종료일이 지났으면 {@code ENDED}, 그 사이는 {@code IN_PROGRESS}다.
*/
@Tag(name = "사물함 회차 관리", description = "운영진 사물함 운영 회차 조회·등록·수정·삭제")
public interface AdminLockerPeriodApi {

/** 회차 목록과 진행 중인 회차. 학생 앱 조회와 달리 아직 게시하지 않은 회차도 함께 보여준다. */
@Operation(summary = "사물함 운영 회차 목록 조회",
description = "운영 회차를 신청 시작 일시 최신순으로 모두 조회한다. 진행 중인 회차는 `currentPeriod`에도 따로 담기며 "
+ "없으면 null이다. 삭제한 회차는 빠진다.")
ApiResponse<AdminLockerPeriodListResponse> getLockerPeriods(AdminApiUser apiUser);

/** 회차 상세. 수정 화면의 기존 값을 채우는 데 쓴다. */
@Operation(summary = "사물함 운영 회차 상세 조회",
description = "운영 회차 한 건을 조회한다. 연도·학기는 수정할 수 없지만 화면 표시를 위해 따로 내려준다. "
+ "삭제한 회차는 찾을 수 없는 것으로 응답한다.")
@ApiErrorCode(type = LockerErrorCode.class, codes = {"LOCKER_PERIOD_NOT_FOUND"})
ApiResponse<AdminLockerPeriodDetailResponse> getLockerPeriod(AdminApiUser apiUser, Long lockerPeriodId);

/** 회차 등록. 등록한 회차는 바로 학생 앱에서 신청할 수 있는 상태가 된다. */
@Operation(summary = "사물함 운영 회차 등록",
description = "운영 회차를 등록한다. 회차 이름은 연도와 학기로 서버가 만든다(예: 2027-1학기). 신청 기간은 "
+ "시작일 00:00:00부터 마감일 23:59:59까지로 저장한다. 같은 학기는 하나만 등록할 수 있고, 회차 기간"
+ "(신청 시작일 ~ 사용 종료일)은 다른 회차와 겹칠 수 없다. "
+ "신청 마감일과 사용 시작일의 선후는 보지 않는다 — 사용을 시작한 뒤 남은 자리를 추가 모집할 수 있다.")
@ApiErrorCode(type = CommonErrorCode.class, codes = {"INVALID_INPUT"})
@ApiErrorCode(type = LockerErrorCode.class,
codes = {"INVALID_LOCKER_PERIOD_RANGE", "LOCKER_PERIOD_SEMESTER_DUPLICATED", "LOCKER_PERIOD_OVERLAPPED"})
ApiResponse<Void> createLockerPeriod(AdminApiUser apiUser, LockerPeriodCreateRequest request);

/** 회차 수정. 전체 교체이므로 바꾸지 않는 값도 원래 값 그대로 보낸다. */
@Operation(summary = "사물함 운영 회차 수정",
description = "운영 회차의 신청·사용 기간을 통째로 바꾼다. 연도·학기와 게시 여부는 바뀌지 않는다. "
+ "종료된 회차는 수정할 수 없고, 진행 중인 회차는 신청 시작일을 다른 값으로 바꿀 수 없다(원래 값을 그대로 보내면 된다). "
+ "바꾼 기간이 다른 회차와 겹쳐도 거부한다.")
@ApiErrorCode(type = CommonErrorCode.class, codes = {"INVALID_INPUT"})
@ApiErrorCode(type = LockerErrorCode.class,
codes = {"LOCKER_PERIOD_NOT_FOUND", "INVALID_LOCKER_PERIOD_RANGE", "LOCKER_PERIOD_ALREADY_ENDED",
"LOCKER_PERIOD_APPLY_ALREADY_STARTED", "LOCKER_PERIOD_OVERLAPPED"})
ApiResponse<Void> updateLockerPeriod(AdminApiUser apiUser, Long lockerPeriodId, LockerPeriodUpdateRequest request);

/** 회차 삭제. 소프트 삭제라 행은 남고 조회에서만 빠진다. */
@Operation(summary = "사물함 운영 회차 삭제",
description = "운영 회차를 삭제한다. 신청이 한 건이라도 있으면 삭제하지 않는다. 데이터는 남기고 조회에서만 빠진다.")
@ApiErrorCode(type = LockerErrorCode.class, codes = {"LOCKER_PERIOD_NOT_FOUND", "LOCKER_PERIOD_HAS_APPLICATIONS"})
ApiResponse<Void> deleteLockerPeriod(AdminApiUser apiUser, Long lockerPeriodId);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
package kr.ac.kookmin.stream.api.admin.event.locker;

import jakarta.validation.Valid;
import kr.ac.kookmin.stream.api.admin.AdminApiUser;
import kr.ac.kookmin.stream.api.admin.event.locker.request.LockerPeriodCreateRequest;
import kr.ac.kookmin.stream.api.admin.event.locker.request.LockerPeriodUpdateRequest;
import kr.ac.kookmin.stream.api.admin.event.locker.response.AdminLockerPeriodDetailResponse;
import kr.ac.kookmin.stream.api.admin.event.locker.response.AdminLockerPeriodListResponse;
import kr.ac.kookmin.stream.api.common.dto.ApiResponse;
import kr.ac.kookmin.stream.event.domain.locker.service.AdminLockerService;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
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;

@RestController
@RequestMapping("/v1/admin/lockers/periods")
@RequiredArgsConstructor
public class AdminLockerPeriodController implements AdminLockerPeriodApi {

private final AdminLockerService adminLockerService;

@Override
@GetMapping
public ApiResponse<AdminLockerPeriodListResponse> getLockerPeriods(AdminApiUser apiUser) {
return ApiResponse.success(AdminLockerPeriodListResponse.from(adminLockerService.getAllPeriods()));
}

@Override
@GetMapping("/{lockerPeriodId}")
public ApiResponse<AdminLockerPeriodDetailResponse> getLockerPeriod(
AdminApiUser apiUser,
@PathVariable Long lockerPeriodId
) {
return ApiResponse.success(
AdminLockerPeriodDetailResponse.from(adminLockerService.getPeriodById(lockerPeriodId)));
}

@Override
@PostMapping
public ApiResponse<Void> createLockerPeriod(
AdminApiUser apiUser,
@Valid @RequestBody LockerPeriodCreateRequest request
) {
adminLockerService.createPeriod(request.toCommand());
return ApiResponse.success();
}

@Override
@PutMapping("/{lockerPeriodId}")
public ApiResponse<Void> updateLockerPeriod(
AdminApiUser apiUser,
@PathVariable Long lockerPeriodId,
@Valid @RequestBody LockerPeriodUpdateRequest request
) {
adminLockerService.updatePeriod(lockerPeriodId, request.toCommand());
return ApiResponse.success();
}

@Override
@DeleteMapping("/{lockerPeriodId}")
public ApiResponse<Void> deleteLockerPeriod(AdminApiUser apiUser, @PathVariable Long lockerPeriodId) {
adminLockerService.deletePeriod(lockerPeriodId);
return ApiResponse.success();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
package kr.ac.kookmin.stream.api.admin.event.locker.request;

import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotNull;
import java.time.LocalDate;
import kr.ac.kookmin.stream.event.domain.locker.domain.LockerPeriodCreateCommand;

/**
* 회차 이름은 연도와 학기로 서버가 만든다. 게시 여부도 받지 않는다 — 학생에게 회차를 노출하는 경로는 홈 화면 배너뿐이고
* 그쪽에서 따로 설정한다.
* <p>
* 신청 기간은 날짜만 받고 시작일 00:00:00 ~ 마감일 23:59:59로 저장한다.
*/
public record LockerPeriodCreateRequest(
@NotNull(message = "연도를 입력해 주세요.")
@Min(value = 2000, message = "연도가 올바르지 않습니다.")
@Max(value = 2100, message = "연도가 올바르지 않습니다.")
Integer year,

@NotNull(message = "학기를 입력해 주세요.")
@Min(value = 1, message = "학기는 1 또는 2여야 합니다.")
@Max(value = 2, message = "학기는 1 또는 2여야 합니다.")
Integer semester,

@NotNull(message = "신청 시작일을 입력해 주세요.")
LocalDate applyStartDate,

@NotNull(message = "신청 마감일을 입력해 주세요.")
LocalDate applyEndDate,

@NotNull(message = "사용 시작일을 입력해 주세요.")
LocalDate usageStartDate,

@NotNull(message = "사용 종료일을 입력해 주세요.")
LocalDate usageEndDate
) {

public LockerPeriodCreateCommand toCommand() {
return new LockerPeriodCreateCommand(
year, semester, applyStartDate, applyEndDate, usageStartDate, usageEndDate);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
package kr.ac.kookmin.stream.api.admin.event.locker.request;

import jakarta.validation.constraints.NotNull;
import java.time.LocalDate;
import kr.ac.kookmin.stream.event.domain.locker.domain.LockerPeriodUpdateCommand;

/**
* 전체 교체다. 바꾸지 않는 값도 원래 값 그대로 보낸다. 연도·학기와 게시 여부는 이 경로로 바뀌지 않는다.
* <p>
* 진행 중인 회차는 신청 시작일을 원래 값 그대로 보내야 한다.
*/
public record LockerPeriodUpdateRequest(
@NotNull(message = "신청 시작일을 입력해 주세요.")
LocalDate applyStartDate,

@NotNull(message = "신청 마감일을 입력해 주세요.")
LocalDate applyEndDate,

@NotNull(message = "사용 시작일을 입력해 주세요.")
LocalDate usageStartDate,

@NotNull(message = "사용 종료일을 입력해 주세요.")
LocalDate usageEndDate
) {

public LockerPeriodUpdateCommand toCommand() {
return new LockerPeriodUpdateCommand(applyStartDate, applyEndDate, usageStartDate, usageEndDate);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
package kr.ac.kookmin.stream.api.admin.event.locker.response;

import java.time.LocalDate;
import java.time.LocalDateTime;
import kr.ac.kookmin.stream.event.domain.locker.domain.LockerPeriod;
import kr.ac.kookmin.stream.event.domain.locker.domain.LockerPeriodStatus;
import kr.ac.kookmin.stream.event.domain.locker.domain.LockerPeriodSummary;

/**
* 수정 화면의 기존 값을 채우는 데 쓴다. 연도·학기는 수정할 수 없지만 화면에 보여줘야 해서 따로 내려준다.
*/
public record AdminLockerPeriodDetailResponse(
Long lockerPeriodId,
String lockerPeriodName,
int year,
int semester,
LocalDateTime applyStartAt,
LocalDateTime applyEndAt,
LocalDate usageStartDate,
LocalDate usageEndDate,
LockerPeriodStatus periodStatus
) {

public static AdminLockerPeriodDetailResponse from(LockerPeriodSummary summary) {
LockerPeriod period = summary.period();
return new AdminLockerPeriodDetailResponse(
period.getId(),
period.getName(),
period.getYear(),
period.getSemester(),
period.getApplyStartAt(),
period.getApplyEndAt(),
period.getUsageStartAt(),
period.getUsageEndAt(),
summary.status()
);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
package kr.ac.kookmin.stream.api.admin.event.locker.response;

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

/**
* @param currentPeriod 진행 중인 회차. 없으면 {@code null}. 회차 기간은 서로 겹치지 않으므로 최대 한 건이다
* @param periods 전체 회차. 진행 중인 회차도 함께 들어 있다
*/
public record AdminLockerPeriodListResponse(
Period currentPeriod,
List<Period> periods
) {

public static AdminLockerPeriodListResponse from(List<LockerPeriodSummary> summaries) {
return new AdminLockerPeriodListResponse(
summaries.stream()
.filter(summary -> summary.status() == LockerPeriodStatus.IN_PROGRESS)
.findFirst()
.map(Period::from)
.orElse(null),
summaries.stream()
.map(Period::from)
.toList()
);
}

public record Period(
Long lockerPeriodId,
String lockerPeriodName,
LocalDateTime applyStartAt,
LocalDateTime applyEndAt,
LocalDate usageStartDate,
LocalDate usageEndDate,
LockerPeriodStatus periodStatus
) {

public static Period from(LockerPeriodSummary summary) {
LockerPeriod period = summary.period();
return new Period(
period.getId(),
period.getName(),
period.getApplyStartAt(),
period.getApplyEndAt(),
period.getUsageStartAt(),
period.getUsageEndAt(),
summary.status()
);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,13 @@ public enum LockerErrorCode implements ErrorCode {

LOCKER_PERIOD_NOT_FOUND(ErrorStatus.NOT_FOUND, "사물함 운영 회차를 찾을 수 없습니다."),
LOCKER_SECTION_NOT_FOUND(ErrorStatus.NOT_FOUND, "사물함 구역을 찾을 수 없습니다."),
INVALID_LOCKER_PERIOD_SEMESTER(ErrorStatus.BAD_REQUEST, "학기는 1 또는 2여야 합니다."),
INVALID_LOCKER_PERIOD_RANGE(ErrorStatus.BAD_REQUEST, "신청 기간과 사용 기간의 순서가 올바르지 않습니다."),
LOCKER_PERIOD_SEMESTER_DUPLICATED(ErrorStatus.CONFLICT, "이미 등록된 학기입니다."),
LOCKER_PERIOD_OVERLAPPED(ErrorStatus.CONFLICT, "다른 회차와 기간이 겹칩니다."),
LOCKER_PERIOD_ALREADY_ENDED(ErrorStatus.CONFLICT, "종료된 회차는 수정할 수 없습니다."),
LOCKER_PERIOD_APPLY_ALREADY_STARTED(ErrorStatus.CONFLICT, "신청이 시작된 회차는 신청 시작일을 바꿀 수 없습니다."),
LOCKER_PERIOD_HAS_APPLICATIONS(ErrorStatus.CONFLICT, "신청이 있는 회차는 삭제할 수 없습니다."),
LOCKER_ALREADY_ASSIGNED(ErrorStatus.CONFLICT, "다른 사용자가 먼저 신청한 사물함입니다.");

private final int status;
Expand Down
Loading