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,60 @@
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.LockerAssignRequest;
import kr.ac.kookmin.stream.api.admin.event.locker.request.LockerPeriodParams;
import kr.ac.kookmin.stream.api.admin.event.locker.response.AdminLockerApplicationSearchResponse;
import kr.ac.kookmin.stream.api.common.dto.ApiResponse;
import kr.ac.kookmin.stream.api.common.dto.PageParams;
import kr.ac.kookmin.stream.api.common.dto.PageResponse;
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 kr.ac.kookmin.stream.welfare.domain.fee.domain.FeeErrorCode;
import org.springdoc.core.annotations.ParameterObject;

/**
* 운영진 사물함 신청 API의 문서 명세. 구현은 {@link AdminLockerApplicationController}가 맡는다.
* <p>
* 접근은 {@code ADMIN} role이면 된다. 부서 제한은 두지 않았다.
*/
@Tag(name = "사물함 신청 관리", description = "운영진 사물함 신청 현황 조회·취소·수동 배정")
public interface AdminLockerApplicationApi {

/** 신청자 이름·학번과 배정된 칸. 취소된 신청도 담는다. */
@Operation(summary = "사물함 신청 현황 조회",
description = "운영 회차의 신청을 신청 일시 최신순으로 조회한다. 이름·학번 일부를 keyword로 넘기면 그 회원만 본다. "
+ "운영진이 취소한 신청도 담기며 canceled로 구분한다. 학생 화면에서는 취소된 신청이 보이지 않는다. "
+ "탈퇴한 회원의 지난 신청은 name·studentId가 null이고, 철거된 사물함은 lockerLabel이 null이다.")
@ApiErrorCode(type = CommonErrorCode.class, codes = {"INVALID_INPUT"})
@ApiErrorCode(type = LockerErrorCode.class, codes = {"LOCKER_PERIOD_NOT_FOUND"})
ApiResponse<PageResponse<AdminLockerApplicationSearchResponse>> getApplications(
AdminApiUser apiUser,
@ParameterObject LockerPeriodParams params,
String keyword,
@ParameterObject PageParams pageParams
);

@Operation(summary = "사물함 신청 취소",
description = "운영진이 신청을 취소한다. 행은 남기고 취소 표시만 바꿔 같은 학생·같은 칸에 다시 배정할 수 있다. "
+ "학생 화면에서는 그 신청이 사라져 미신청으로 보이고, 취소됐다는 사실은 알림으로 전달한다. "
+ "사용 기간이 끝난 회차의 신청은 되돌릴 자리가 없어 거부한다.")
@ApiErrorCode(type = LockerErrorCode.class,
codes = {"LOCKER_APPLICATION_NOT_FOUND", "LOCKER_PERIOD_NOT_FOUND", "LOCKER_PERIOD_ALREADY_ENDED",
"LOCKER_APPLICATION_ALREADY_CANCELED"})
ApiResponse<Void> cancelApplication(AdminApiUser apiUser, Long lockerApplicationId);

@Operation(summary = "사물함 수동 배정",
description = "운영진이 학생회비 납부자에게 사물함을 직접 배정한다. 신청 기간은 보지 않는다 — 신청을 놓쳤거나 "
+ "자리를 옮겨야 하는 학생에게 쓰는 기능이라 기간이 끝난 뒤가 오히려 주 사용 시점이다. "
+ "사용 기간이 끝난 회차와 이미 신청된 칸은 거부하고, 한 학생이 한 회차에 두 칸을 가질 수 없다. "
+ "응답 본문이 없으므로 결과는 신청 현황을 다시 조회해 받는다.")
@ApiErrorCode(type = CommonErrorCode.class, codes = {"INVALID_INPUT"})
@ApiErrorCode(type = FeeErrorCode.class, codes = {"MEMBER_IS_NOT_PAYER"})
@ApiErrorCode(type = LockerErrorCode.class,
codes = {"LOCKER_PERIOD_NOT_FOUND", "LOCKER_PERIOD_ALREADY_ENDED", "LOCKER_NOT_IN_SECTION",
"LOCKER_ALREADY_ASSIGNED", "MEMBER_ALREADY_HAS_LOCKER"})
ApiResponse<Void> assignLocker(AdminApiUser apiUser, LockerAssignRequest request);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
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.LockerAssignRequest;
import kr.ac.kookmin.stream.api.admin.event.locker.request.LockerPeriodParams;
import kr.ac.kookmin.stream.api.admin.event.locker.response.AdminLockerApplicationSearchResponse;
import kr.ac.kookmin.stream.api.admin.event.locker.usecase.AdminLockerApplicationSearchUseCase;
import kr.ac.kookmin.stream.api.admin.event.locker.usecase.AdminLockerAssignUseCase;
import kr.ac.kookmin.stream.api.admin.event.locker.usecase.MemberLockerApplication;
import kr.ac.kookmin.stream.api.common.dto.ApiResponse;
import kr.ac.kookmin.stream.api.common.dto.PageParams;
import kr.ac.kookmin.stream.api.common.dto.PageResponse;
import kr.ac.kookmin.stream.common.PageResult;
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.ModelAttribute;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("/v1/admin/lockers/applications")
@RequiredArgsConstructor
public class AdminLockerApplicationController implements AdminLockerApplicationApi {

private final AdminLockerApplicationSearchUseCase adminLockerApplicationSearchUseCase;
private final AdminLockerAssignUseCase adminLockerAssignUseCase;
private final AdminLockerService adminLockerService;

@Override
@GetMapping
public ApiResponse<PageResponse<AdminLockerApplicationSearchResponse>> getApplications(
AdminApiUser apiUser,
@Valid @ModelAttribute LockerPeriodParams params,
@RequestParam(required = false) String keyword,
@Valid @ModelAttribute PageParams pageParams
) {
PageResult<MemberLockerApplication> result = adminLockerApplicationSearchUseCase.search(
params.lockerPeriodId(), keyword, pageParams.toOffset());

return ApiResponse.success(PageResponse.from(result, vo -> AdminLockerApplicationSearchResponse.of(
vo.result().application(), vo.result().locker(), vo.member())));
}

@Override
@DeleteMapping("/{lockerApplicationId}")
public ApiResponse<Void> cancelApplication(AdminApiUser apiUser, @PathVariable Long lockerApplicationId) {
adminLockerService.cancelApplication(lockerApplicationId);
return ApiResponse.success();
}

@Override
@PostMapping
public ApiResponse<Void> assignLocker(AdminApiUser apiUser, @Valid @RequestBody LockerAssignRequest request) {
adminLockerAssignUseCase.assign(request.lockerPeriodId(), request.memberId(), request.lockerId());
return ApiResponse.success();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
package kr.ac.kookmin.stream.api.admin.event.locker.request;

import jakarta.validation.constraints.NotNull;

/**
* 운영진이 학생에게 사물함을 직접 배정한다.
* <p>
* 회원은 식별자로 받는다. 운영진 화면이 학생회비 납부자 목록을 이름·학번으로 검색해 고르는 구조라, 거기서 받은
* 식별자를 그대로 넘기면 된다.
*/
public record LockerAssignRequest(

@NotNull(message = "사물함 운영 회차를 입력해 주세요.")
Long lockerPeriodId,

@NotNull(message = "배정할 학생을 선택해 주세요.")
Long memberId,

@NotNull(message = "배정할 사물함을 선택해 주세요.")
Long lockerId
) {
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@
import jakarta.validation.constraints.NotNull;

/**
* 구역 조회의 기준 회차. 구역은 운영 회차에 묶여 있지 않고, 회차는 "어느 회차의 배정 현황을 볼지"를 정한다.
* 운영진 조회의 기준 회차. 구역 조회와 신청 현황 조회가 함께 쓴다. 구역은 운영 회차에 묶여 있지 않고,
* 회차는 "어느 회차의 배정 현황을 볼지"를 정한다.
* <p>
* 필드가 하나뿐이지만 {@code @RequestParam}으로 받지 않는다. {@code GlobalExceptionHandler}가
* {@code MissingServletRequestParameterException}을 다루지 않아 값이 빠지면 500이 되고,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
package kr.ac.kookmin.stream.api.admin.event.locker.response;

import java.time.LocalDateTime;
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.member.domain.member.domain.Member;

/**
* 운영진 신청 현황의 한 줄.
*
* @param name 신청자 이름. 탈퇴한 회원의 지난 신청이면 {@code null}
* @param studentId 신청자 학번. 탈퇴한 회원의 지난 신청이면 {@code null}
* @param lockerLabel 배정된 사물함 이름. 철거된 사물함도 이름은 보여준다
* @param canceled 운영진이 취소했는지. 기간이 끝났는지는 회차의 사용 종료일로 화면이 가른다
*/
public record AdminLockerApplicationSearchResponse(
Long lockerApplicationId,
Long memberId,
String name,
String studentId,
String lockerLabel,
LocalDateTime appliedAt,
boolean canceled
) {

public static AdminLockerApplicationSearchResponse of(
LockerApplication application, Locker locker, Member member
) {
return new AdminLockerApplicationSearchResponse(
application.getId(),
application.getMemberId(),
member == null ? null : member.getName(),
member == null ? null : member.getStudentId(),
locker == null ? null : locker.getLockerLabel(),
application.getAppliedAt(),
application.isCanceled()
);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
package kr.ac.kookmin.stream.api.admin.event.locker.usecase;

import java.util.List;
import java.util.Map;
import kr.ac.kookmin.stream.common.PageOffset;
import kr.ac.kookmin.stream.common.PageResult;
import kr.ac.kookmin.stream.event.domain.locker.domain.LockerApplication;
import kr.ac.kookmin.stream.event.domain.locker.domain.LockerApplicationResult;
import kr.ac.kookmin.stream.event.domain.locker.service.AdminLockerService;
import kr.ac.kookmin.stream.member.domain.member.domain.Member;
import kr.ac.kookmin.stream.member.domain.member.service.MemberService;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;

@Component
@RequiredArgsConstructor
public class AdminLockerApplicationSearchUseCase {

private final AdminLockerService adminLockerService;
private final MemberService memberService;

/**
* 운영 회차의 신청 현황에 신청자 정보를 붙인다. 취소된 신청도 담는다.
*
* @param keyword 이름·학번 일부. 비어 있으면 회차 전체를 본다
*/
public PageResult<MemberLockerApplication> search(Long lockerPeriodId, String keyword, PageOffset pageOffset) {
List<Long> memberIds = null;

if (keyword != null && !keyword.isBlank()) {
memberIds = memberService.searchIdsByKeyword(keyword);

if (memberIds.isEmpty()) {
return PageResult.empty(pageOffset.page(), pageOffset.size());
}
}

PageResult<LockerApplicationResult> result =
adminLockerService.getApplications(lockerPeriodId, memberIds, pageOffset);
List<Long> resultMemberIds = result.content().stream()
.map(applicationResult -> applicationResult.application().getMemberId())
.toList();

// 탈퇴(소프트 삭제)한 회원은 getMapByIds에서 빠지므로, 그 회원의 지난 신청은 member가 null로 남는다
Map<Long, Member> memberMap = memberService.getMapByIds(resultMemberIds);

return result.map(applicationResult -> new MemberLockerApplication(
applicationResult, memberMap.getOrDefault(applicationResult.application().getMemberId(), null)));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
package kr.ac.kookmin.stream.api.admin.event.locker.usecase;

import kr.ac.kookmin.stream.event.domain.locker.service.AdminLockerService;
import kr.ac.kookmin.stream.welfare.domain.fee.service.PayerService;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;

@Component
@RequiredArgsConstructor
public class AdminLockerAssignUseCase {

private final PayerService payerService;
private final AdminLockerService adminLockerService;

/**
* 학생회비 납부자인지 확인하고 사물함을 배정한다. 운영진 화면이 납부자 목록에서 학생을 고르게 돼 있어 같은 기준을
* 서버에서도 지킨다.
* <p>
* 납부 여부는 회비 도메인이, 배정은 사물함 도메인이 안다. 도메인끼리 부르지 않고 여기서 묶는다
* ({@code architecture.md} 6-1절).
* <p>
* 트랜잭션을 걸지 않는다. 납부자 확인은 읽기이고 그 전에 쓰는 것이 없어, 실패해도 되돌릴 것이 없다.
* 쓰기는 {@code assignLocker}가 자기 트랜잭션에서 한다.
*/
public void assign(Long lockerPeriodId, Long memberId, Long lockerId) {
payerService.validatePayer(memberId);
adminLockerService.assignLocker(lockerPeriodId, memberId, lockerId);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package kr.ac.kookmin.stream.api.admin.event.locker.usecase;

import kr.ac.kookmin.stream.event.domain.locker.domain.LockerApplicationResult;
import kr.ac.kookmin.stream.member.domain.member.domain.Member;

// AdminLockerApplicationSearchUseCase가 조합한 결과. DTO 변환은 Controller가 한다
// (PageResult<도메인>까지만 UseCase가 만들고, api DTO 조립은 경계에서 하기 위함).
// member는 탈퇴(소프트 삭제)한 회원의 지난 신청이면 null일 수 있다.
public record MemberLockerApplication(LockerApplicationResult result, Member member) {
}
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ ApiResponse<LockerSectionDetailResponse> getSectionLockers(
/** 고른 사물함을 신청하고 즉시 배정한다. */
@Operation(summary = "사물함 신청",
description = "게시된 운영 회차에서 고른 사물함을 신청하고 즉시 배정한다. 모집 기간 안에서만 받는다. "
+ "운영 회차당 한 개만 신청할 수 있고 신청 후에는 취소하거나 변경할 수 없다. "
+ "운영 회차당 한 개만 신청할 수 있고 신청 후에는 직접 취소하거나 변경할 수 없다. "
+ "같은 사물함에 동시에 신청하면 먼저 완료된 신청만 성공한다.")
@ApiErrorCode(type = CommonErrorCode.class, codes = {"INVALID_INPUT"})
@ApiErrorCode(type = LockerErrorCode.class,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
package kr.ac.kookmin.stream.common.event;

/** 운영진이 회원에게 사물함을 직접 배정했다. 회원이 신청한 적 없는 배정이라 {@link LockerAssigned}와 문구가 다르다. */
public record LockerAssignedByAdmin(Long memberId, Long lockerPeriodId) implements DomainEvent {}
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
package kr.ac.kookmin.stream.common.event;

/** 운영진이 회원의 사물함 배정을 취소했다. 회원 화면에서는 신청이 사라지므로 알림이 유일한 전달 수단이다. */
public record LockerAssignmentCanceled(Long memberId, Long lockerPeriodId) implements DomainEvent {}
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package kr.ac.kookmin.stream.event.domain.locker.domain;

import java.time.LocalDateTime;
import kr.ac.kookmin.stream.common.BusinessException;
import lombok.AccessLevel;
import lombok.AllArgsConstructor;
import lombok.EqualsAndHashCode;
Expand All @@ -16,15 +17,18 @@ public class LockerApplication {
private Long memberId;
private Long lockerId;
private LocalDateTime appliedAt;
/** 운영진이 취소했는지. 취소해도 행은 남기므로 살아 있는 신청인지는 이 값으로 가른다. */
private boolean canceled;

public static LockerApplication of(
Long id,
Long lockerPeriodId,
Long memberId,
Long lockerId,
LocalDateTime appliedAt
LocalDateTime appliedAt,
boolean canceled
) {
return new LockerApplication(id, lockerPeriodId, memberId, lockerId, appliedAt);
return new LockerApplication(id, lockerPeriodId, memberId, lockerId, appliedAt, canceled);
}

/**
Expand All @@ -33,6 +37,17 @@ public static LockerApplication of(
* @param appliedAt 신청 시각. 클라이언트가 보내지 않고 서버 시각으로 기록한다
*/
public static LockerApplication create(Long lockerPeriodId, Long memberId, Long lockerId, LocalDateTime appliedAt) {
return new LockerApplication(null, lockerPeriodId, memberId, lockerId, appliedAt);
return new LockerApplication(null, lockerPeriodId, memberId, lockerId, appliedAt, false);
}

/**
* 운영진이 신청을 취소한다. 행은 남기고 표시만 바꾼다 — 운영진이 그 칸이 왜 비었는지 알아야 하고,
* 같은 학생·같은 칸에 다시 배정할 수 있어야 한다.
*/
public void cancel() {
if (canceled) {
throw new BusinessException(LockerErrorCode.LOCKER_APPLICATION_ALREADY_CANCELED);
}
this.canceled = true;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,10 @@ public enum LockerErrorCode implements ErrorCode {
LOCKER_APPLY_NOT_OPEN(ErrorStatus.CONFLICT, "사물함 신청 기간이 아닙니다."),
LOCKER_NOT_IN_SECTION(ErrorStatus.BAD_REQUEST, "해당 구역의 사물함이 아닙니다."),
LOCKER_IN_USE(ErrorStatus.CONFLICT, "이용 중인 사물함은 사용 중지할 수 없습니다."),
LOCKER_ALREADY_ASSIGNED(ErrorStatus.CONFLICT, "다른 사용자가 먼저 신청한 사물함입니다.");
LOCKER_ALREADY_ASSIGNED(ErrorStatus.CONFLICT, "다른 사용자가 먼저 신청한 사물함입니다."),
MEMBER_ALREADY_HAS_LOCKER(ErrorStatus.CONFLICT, "이미 이 회차에 사물함을 배정받은 학생입니다."),
LOCKER_APPLICATION_NOT_FOUND(ErrorStatus.NOT_FOUND, "사물함 신청을 찾을 수 없습니다."),
LOCKER_APPLICATION_ALREADY_CANCELED(ErrorStatus.CONFLICT, "이미 취소된 신청입니다.");

private final int status;
private final String message;
Expand Down
Loading