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
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.LockerService;
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 LockerService lockerService;

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

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

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

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

@Override
@DeleteMapping("/{lockerPeriodId}")
public ApiResponse<Void> deleteLockerPeriod(AdminApiUser apiUser, @PathVariable Long lockerPeriodId) {
lockerService.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,31 @@
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 AdminLockerPeriodListItemResponse(
Long lockerPeriodId,
String lockerPeriodName,
LocalDateTime applyStartAt,
LocalDateTime applyEndAt,
LocalDate usageStartDate,
LocalDate usageEndDate,
LockerPeriodStatus periodStatus
) {

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

import java.util.List;
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(
AdminLockerPeriodListItemResponse currentPeriod,
List<AdminLockerPeriodListItemResponse> periods
) {

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