Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@
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.archive.request.ArchiveExposureChangeRequest;
import kr.ac.kookmin.stream.api.admin.event.archive.request.ArchiveSearchParams;
import kr.ac.kookmin.stream.api.admin.event.archive.request.ArchiveWriteRequest;
import kr.ac.kookmin.stream.api.admin.event.archive.response.AdminArchiveDetailResponse;
import kr.ac.kookmin.stream.api.admin.event.archive.response.AdminArchiveListItemResponse;
import kr.ac.kookmin.stream.api.common.dto.ApiResponse;
Expand All @@ -12,14 +14,16 @@
import kr.ac.kookmin.stream.api.common.openapi.ApiErrorCode;
import kr.ac.kookmin.stream.common.CommonErrorCode;
import kr.ac.kookmin.stream.event.domain.archive.domain.ArchiveErrorCode;
import kr.ac.kookmin.stream.event.domain.event.domain.EventErrorCode;
import kr.ac.kookmin.stream.file.domain.FileErrorCode;
import org.springdoc.core.annotations.ParameterObject;

/**
* 운영진 아카이빙 API의 문서 명세. 구현은 {@link AdminArchiveController}가 맡는다.
* <p>
* 접근은 {@code ADMIN} role이면 된다. 부서 제한은 두지 않았다.
*/
@Tag(name = "아카이빙 관리", description = "운영진 아카이빙 목록·상세 조회")
@Tag(name = "아카이빙 관리", description = "운영진 아카이빙 목록·상세 조회와 등록·수정·삭제")
public interface AdminArchiveApi {

/** 아카이빙 목록. 제목으로 검색하고 슬랑제 노출 여부로 거르며, 등록일 최신순으로 페이지 단위 조회한다. */
Expand All @@ -44,4 +48,40 @@ ApiResponse<PageResponse<AdminArchiveListItemResponse>> getArchives(
+ "연결하지 않았거나 행사가 삭제됐으면 null이다 — 운영진 화면이므로 아직 게시하지 않은 행사도 보여준다.")
@ApiErrorCode(type = ArchiveErrorCode.class, codes = {"ARCHIVE_NOT_FOUND"})
ApiResponse<AdminArchiveDetailResponse> getArchive(AdminApiUser apiUser, Long archiveId);

@Operation(summary = "아카이빙 등록",
description = "아카이빙을 새로 등록한다. 활동 사진은 한 장 이상이어야 하고 첫 장이 대표 이미지다. "
+ "당일 활동은 endDate를 생략한다 — 종료일을 주면 시작일보다 앞설 수 없다. "
+ "relatedEventId로 행사를 연결하며, 아직 게시하지 않은 행사도 연결할 수 있다. "
+ "응답 본문이 없으므로 등록 결과는 목록을 다시 조회해 받는다.")
@ApiErrorCode(type = CommonErrorCode.class, codes = {"INVALID_INPUT"})
@ApiErrorCode(type = ArchiveErrorCode.class, codes = {"INVALID_ARCHIVE_PERIOD"})
@ApiErrorCode(type = FileErrorCode.class, codes = {"FILE_NOT_FOUND"})
@ApiErrorCode(type = EventErrorCode.class, codes = {"EVENT_NOT_FOUND"})
ApiResponse<Void> createArchive(AdminApiUser apiUser, ArchiveWriteRequest request);

@Operation(summary = "아카이빙 수정",
description = "아카이빙을 통째로 바꾼다. 보낸 값이 그대로 저장되므로 고치지 않을 필드도 함께 보내야 한다. "
+ "imageIds는 최종 사진 목록이고, relatedEventId를 null로 주면 행사 연결이 끊긴다. "
+ "응답 본문이 없으므로 바뀐 결과는 상세를 다시 조회해 받는다.")
@ApiErrorCode(type = CommonErrorCode.class, codes = {"INVALID_INPUT"})
@ApiErrorCode(type = ArchiveErrorCode.class, codes = {"ARCHIVE_NOT_FOUND", "INVALID_ARCHIVE_PERIOD"})
@ApiErrorCode(type = FileErrorCode.class, codes = {"FILE_NOT_FOUND"})
@ApiErrorCode(type = EventErrorCode.class, codes = {"EVENT_NOT_FOUND"})
ApiResponse<Void> updateArchive(AdminApiUser apiUser, Long archiveId, ArchiveWriteRequest request);

@Operation(summary = "아카이빙 삭제",
description = "아카이빙을 삭제한다. 행은 남기고 표시만 바꿔 되살릴 수 있게 하며, 학생 앱 조회에서는 바로 빠진다. "
+ "이미 삭제된 아카이빙은 없는 것으로 본다.")
@ApiErrorCode(type = ArchiveErrorCode.class, codes = {"ARCHIVE_NOT_FOUND"})
ApiResponse<Void> deleteArchive(AdminApiUser apiUser, Long archiveId);

@Operation(summary = "아카이빙 슬랑제 노출 여부 변경",
description = "여러 아카이빙의 슬랑제 페이지 노출 여부를 한 번에 바꾼다. 목록의 체크박스 일괄 변경과 "
+ "행별 드롭다운 변경에 함께 쓰며, 행별 변경은 archiveIds에 하나만 담아 보낸다. "
+ "한 번에 1개 이상 100개 이하로 보내고, 하나라도 없거나 삭제된 아카이빙이 섞이면 아무것도 바뀌지 않는다. "
+ "응답 본문이 없으므로 바뀐 결과는 목록을 다시 조회해 받는다.")
@ApiErrorCode(type = CommonErrorCode.class, codes = {"INVALID_INPUT"})
@ApiErrorCode(type = ArchiveErrorCode.class, codes = {"ARCHIVE_NOT_FOUND"})
ApiResponse<Void> changeExposure(AdminApiUser apiUser, ArchiveExposureChangeRequest request);
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,13 @@
import jakarta.validation.Valid;
import java.util.Map;
import kr.ac.kookmin.stream.api.admin.AdminApiUser;
import kr.ac.kookmin.stream.api.admin.event.archive.request.ArchiveExposureChangeRequest;
import kr.ac.kookmin.stream.api.admin.event.archive.request.ArchiveSearchParams;
import kr.ac.kookmin.stream.api.admin.event.archive.request.ArchiveWriteRequest;
import kr.ac.kookmin.stream.api.admin.event.archive.response.AdminArchiveDetailResponse;
import kr.ac.kookmin.stream.api.admin.event.archive.response.AdminArchiveListItemResponse;
import kr.ac.kookmin.stream.api.admin.event.archive.usecase.AdminArchiveDetailUseCase;
import kr.ac.kookmin.stream.api.admin.event.archive.usecase.AdminArchiveWriteUseCase;
import kr.ac.kookmin.stream.api.admin.event.archive.usecase.ArchiveWithRelatedEvent;
import kr.ac.kookmin.stream.api.common.dto.ApiResponse;
import kr.ac.kookmin.stream.api.common.dto.PageParams;
Expand All @@ -17,9 +20,14 @@
import kr.ac.kookmin.stream.file.domain.File;
import kr.ac.kookmin.stream.file.service.FileService;
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.PatchMapping;
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;

Expand All @@ -34,6 +42,7 @@ public class AdminArchiveController implements AdminArchiveApi {

private final AdminArchiveService adminArchiveService;
private final AdminArchiveDetailUseCase adminArchiveDetailUseCase;
private final AdminArchiveWriteUseCase adminArchiveWriteUseCase;
private final FileService fileService;

@Override
Expand Down Expand Up @@ -61,4 +70,42 @@ public ApiResponse<AdminArchiveDetailResponse> getArchive(
return ApiResponse.success(
AdminArchiveDetailResponse.of(archive.detail(), archive.relatedEventTitle(), imagesById));
}

@Override
@PostMapping
public ApiResponse<Void> createArchive(
AdminApiUser apiUser,
@Valid @RequestBody ArchiveWriteRequest request
) {
adminArchiveWriteUseCase.create(request.toCommand());
return ApiResponse.success();
}

@Override
@PutMapping("/{archiveId}")
public ApiResponse<Void> updateArchive(
AdminApiUser apiUser,
@PathVariable Long archiveId,
@Valid @RequestBody ArchiveWriteRequest request
) {
adminArchiveWriteUseCase.update(archiveId, request.toCommand());
return ApiResponse.success();
}

@Override
@DeleteMapping("/{archiveId}")
public ApiResponse<Void> deleteArchive(AdminApiUser apiUser, @PathVariable Long archiveId) {
adminArchiveService.delete(archiveId);
return ApiResponse.success();
}

@Override
@PatchMapping("/exposure")

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.

[a] 리뷰 포인트 주신 경로·필드 이름에 대한 의견이에요. 바꾸신 이름이 좋아 보여요.

  • 필드: 같은 스택의 목록 필터 ArchiveSearchParams.isExposed, 응답 AdminArchiveListItemResponse·AdminArchiveDetailResponse의 isExposed, 이번 두 요청이 모두 isExposed로 맞아 있고, 행사 공개 전환의 EventPublishRequest.isPublished와도 모양이 같아요
  • 경로: 여러 개를 한 번에 바꾸는 PATCH /v1/admin/lockers/sections/{sectionId}/lockers/status처럼 바꿀 속성 이름을 경로 끝에 두는 형태라 /exposure도 자연스러워요

관리자 프론트가 명세 이름으로 이미 붙였는지만 확인해 주시고, 붙였다면 바꾸는 시점을 프론트와 맞춰 주시면 될 것 같아요.

public ApiResponse<Void> changeExposure(
AdminApiUser apiUser,
@Valid @RequestBody ArchiveExposureChangeRequest request
) {
adminArchiveService.changeExposure(request.archiveIds(), request.isExposed());
return ApiResponse.success();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
package kr.ac.kookmin.stream.api.admin.event.archive.request;

import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import java.util.List;

/**
* 여러 아카이빙의 슬랑제 페이지 노출 여부를 한 번에 바꾼다.
* <p>
* 목록의 체크박스 일괄 변경과 행별 드롭다운 변경이 같은 동작이라 하나로 받는다 — 행별 변경은 식별자가 하나인 요청이다.
*/
public record ArchiveExposureChangeRequest(

// @Size는 null을 통과시키므로 @NotEmpty가 같이 있어야 목록을 빠뜨린 요청이 400으로 나간다.
// 상한은 목록에서 체크박스로 고르는 동작이라 한 페이지 최대와 같게 둔다(PageParams의 size 상한)
@NotEmpty(message = "아카이빙을 하나 이상 선택해 주세요.")
@Size(max = 100, message = "아카이빙은 한 번에 100개까지 바꿀 수 있습니다.")
List<@NotNull(message = "아카이빙 ID는 비워 둘 수 없습니다.") Long> archiveIds,

@NotNull(message = "슬랑제 페이지 노출 여부를 입력해 주세요.")
Boolean isExposed
) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
package kr.ac.kookmin.stream.api.admin.event.archive.request;

import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import java.time.LocalDate;
import java.util.List;
import kr.ac.kookmin.stream.event.domain.archive.domain.ArchiveWriteCommand;

/**
* 아카이빙 등록과 수정이 같은 본문을 받는다. 수정이 전체 필드를 통째로 바꾸는 방식이라 등록과 모양이 같아,
* 두 record로 나누면 같은 선언이 두 벌 생긴다.
* <p>
* 종료일이 시작일보다 앞서는지는 여기서 보지 않는다. 값 하나의 형식이 아니라 두 값 사이의 규칙이라
* {@code Archive}가 본다.
*
* @param endDate 당일 활동이면 생략하거나 {@code null}
* @param imageIds 활동 사진. 배열 순서가 노출 순서이고 첫 장이 대표 이미지다
* @param relatedEventId 연결할 행사. 연결하지 않으면 {@code null}이고, 수정에서 {@code null}이면 연결을 끊는다
*/
public record ArchiveWriteRequest(

@NotBlank(message = "활동 제목을 입력해 주세요.")
@Size(max = 200, message = "활동 제목은 200자 이하여야 합니다.")
String title,

// content는 TEXT 컬럼(최대 65,535바이트)이다. 글자당 최대 4바이트로 잡아도 넘치지 않는 길이로 막는다
@NotBlank(message = "활동 내용을 입력해 주세요.")

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.

[c] content에 길이 상한이 없어서 아주 긴 본문이 오면 500이 날 것 같아요. content는 TEXT 컬럼(최대 65,535바이트)인데 요청은 @NotBlank만 보고 있어요.

예를 들어 운영진이 한글 2만 2천 자쯤(약 66,000바이트) 되는 본문을 붙여 넣으면

  • @Valid는 통과하고
  • 저장할 때 MySQL이 길이 초과로 실패해서
  • GlobalExceptionHandler의 Exception 핸들러로 가 INTERNAL_SERVER_ERROR(500)가 나가요

등록과 수정이 같은 record를 써서 두 API 모두 해당돼요. 공지·행사·피드백 답변과 같은 기준으로 막아 두면 좋겠어요.

Suggested change
@NotBlank(message = "활동 내용을 입력해 주세요.")
// content는 TEXT 컬럼(최대 65,535바이트)이다. 글자당 최대 4바이트로 잡아도 넘치지 않는 길이로 막는다
@NotBlank(message = "활동 내용을 입력해 주세요.")
@Size(max = 16000, message = "활동 내용은 16000자 이하여야 합니다.")

선례: NoticeCreateRequest.java:20, EventCreateRequest.java:24, FeedbackAnswerSaveRequest.java:49

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

3598400 @Size(max = 16000)을 걸었습니다. 글자당 4바이트로 잡아도 TEXT 상한을 넘지 않는 길이고, 등록·수정이 같은 record를 써서 두 API에 같이 걸립니다

@Size(max = 16000, message = "활동 내용은 16000자 이하여야 합니다.")
String content,

@NotNull(message = "활동 시작일을 입력해 주세요.")
LocalDate startDate,

LocalDate endDate,

@Size(max = 255, message = "활동 장소는 255자 이하여야 합니다.")
String location,

@NotBlank(message = "담당 부서를 입력해 주세요.")
@Size(max = 100, message = "담당 부서는 100자 이하여야 합니다.")
String departmentName,

// 사진 없는 아카이빙은 목록에서 썸네일이 빈 칸으로 보인다. 첫 장이 대표 이미지라 한 장은 있어야 한다
@NotEmpty(message = "활동 사진을 한 장 이상 등록해 주세요.")

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.

[a] 리뷰 포인트 주신 사진 최소 한 장에 대한 의견이에요. 강제하는 쪽이 좋아 보여요.

  • 학생 앱 목록의 thumbnailUrl이 Archive.thumbnailFileId()(사진 첫 장)에서 나와서, 사진을 비우면 ArchiveListItemResponse에 thumbnailUrl이 null로 내려가요
  • "필수"는 입력 형식이라 요청 record의 @NotEmpty로 INVALID_INPUT을 주는 것도 coding-style.md 3절과 맞아요
  • 명세에는 선택으로 돼 있으니 프론트에도 필수로 바뀐다는 걸 같이 공유해 주시면 좋겠어요

덧붙여 최대 장수도 명세에 있다면 EventCreateRequest.java:63의 @Size(max = 10)처럼 여기서 같이 막아 두면 좋겠습니다.

List<@NotNull(message = "파일 ID는 비워 둘 수 없습니다.") Long> imageIds,

@NotNull(message = "슬랑제 페이지 노출 여부를 입력해 주세요.")
Boolean isExposed,

Long relatedEventId
) {

public ArchiveWriteCommand toCommand() {
return new ArchiveWriteCommand(
title, content, startDate, endDate, location, departmentName, distinctImageIds(), isExposed,
relatedEventId);
}

/**
* 중복을 걷어낸 사진 식별자. 같은 사진을 두 번 보여줄 일이 없어 저장 전에 뺀다. 처음 나온 자리를 지켜 노출
* 순서는 그대로 둔다.
*/
private List<Long> distinctImageIds() {
return imageIds.stream().distinct().toList();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
package kr.ac.kookmin.stream.api.admin.event.archive.usecase;

import java.util.List;
import java.util.Set;
import kr.ac.kookmin.stream.common.BusinessException;
import kr.ac.kookmin.stream.event.domain.archive.domain.ArchiveWriteCommand;
import kr.ac.kookmin.stream.event.domain.archive.service.AdminArchiveService;
import kr.ac.kookmin.stream.event.domain.event.domain.EventErrorCode;
import kr.ac.kookmin.stream.event.domain.event.service.EventService;
import kr.ac.kookmin.stream.file.domain.FileErrorCode;
import kr.ac.kookmin.stream.file.service.FileService;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;

/**
* 아카이빙을 쓰기 전에 연결할 사진과 행사가 실제로 있는지 확인한다. 사진(file)과 행사는 아카이빙과 다른 도메인이라
* 도메인끼리 직접 의존하지 않고 이 UseCase가 조합한다({@code architecture.md} 6-1절).
* <p>
* 확인과 저장을 한 트랜잭션으로 묶지 않는다. 확인 직후 사진이 지워져도 상세 조회가 목록에서 조용히 빼고, 행사가
* 지워지면 연결만 비어 보인다 — 둘 다 아카이빙을 못 쓰게 만들지 않는다. 공지와 같은 판단이다
* ({@code AdminNoticeWriteUseCase}).
*/
@Component
@RequiredArgsConstructor
public class AdminArchiveWriteUseCase {

private final FileService fileService;
private final EventService eventService;
private final AdminArchiveService adminArchiveService;

public void create(ArchiveWriteCommand command) {
validateImagesExist(command.imageIds());
validateRelatedEventExists(command.relatedEventId());

adminArchiveService.create(command);
}

public void update(Long archiveId, ArchiveWriteCommand command) {
validateImagesExist(command.imageIds());
validateRelatedEventExists(command.relatedEventId());

adminArchiveService.update(archiveId, command);
}

/**
* 조회된 개수를 견줘 전부 있는지 본다. 중복이 남아 있으면 있는 파일도 없다고 오판하므로 호출부에 맡기지 않고
* 여기서 묶는다.
*/
private void validateImagesExist(List<Long> imageIds) {
Set<Long> fileIds = Set.copyOf(imageIds);

if (fileService.findAllByIdIn(List.copyOf(fileIds)).size() != fileIds.size()) {
throw new BusinessException(FileErrorCode.FILE_NOT_FOUND);
}
}

/**
* 없는 행사를 연결하면 상세에서 조용히 비어 보여 운영진이 눈치채지 못한다. 쓰는 자리에서 막는다.
* 아직 게시하지 않은 행사는 연결할 수 있다 — 운영진 화면은 미게시 행사도 고를 수 있어야 한다.
*/
private void validateRelatedEventExists(Long relatedEventId) {
if (relatedEventId != null && eventService.getByIdOrNull(relatedEventId) == null) {
throw new BusinessException(EventErrorCode.EVENT_NOT_FOUND);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.List;
import kr.ac.kookmin.stream.common.BusinessException;
import lombok.AccessLevel;
import lombok.AllArgsConstructor;
import lombok.EqualsAndHashCode;
Expand Down Expand Up @@ -45,10 +46,65 @@ public static Archive of(
exposed, relatedEventId, createdAt);
}

/** 등록 일시는 DB 기본값으로 채워져 저장 전에는 비어 있다. */
public static Archive create(ArchiveWriteCommand command) {
validatePeriod(command.startDate(), command.endDate());

return new Archive(
null,
command.title(),
command.content(),
command.startDate(),
command.endDate(),
command.location(),
command.departmentName(),
command.imageIds(),
command.exposed(),
command.relatedEventId(),
null
);
}

/**
* 전체 필드를 통째로 바꾼다. 사진과 연결 행사도 받은 값으로 교체하므로, {@code relatedEventId}가
* {@code null}이면 연결을 끊는다.
*/
public void update(ArchiveWriteCommand command) {
validatePeriod(command.startDate(), command.endDate());

this.title = command.title();
this.content = command.content();
this.startDate = command.startDate();
this.endDate = command.endDate();
this.location = command.location();
this.departmentName = command.departmentName();
this.imageIds = command.imageIds();
this.exposed = command.exposed();
this.relatedEventId = command.relatedEventId();
}

/**
* 노출 여부를 바꾼다. 이미 그 값이어도 그대로 둔다 — 영속성 컨텍스트가 값이 바뀐 행만 UPDATE한다.
*/
public void changeExposure(boolean exposed) {
this.exposed = exposed;
}

/**
* 당일 활동은 종료일이 없다. 종료일을 줬다면 시작일보다 앞설 수 없다.
* <p>
* 값 하나의 형식이 아니라 두 값 사이의 규칙이라 요청 어노테이션으로는 표현할 수 없어 도메인에서 본다.
*/
private static void validatePeriod(LocalDate startDate, LocalDate endDate) {

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.

[a] 시작일·종료일을 EventPeriod처럼 기간 VO로 묶는 건 어떨까요? 같은 모듈의 EventPeriod가 "종료가 시작보다 늦어야 한다는 순서는 이 값 자체의 불변식이라 of에서 검증한다"는 기준으로 만들어져 있어서, 아카이빙도 같은 모양이면 행사와 아카이빙이 기간 규칙을 같은 방식으로 다루게 될 것 같아요.

// core:domain:event — archive/domain
/**
 * 활동 기간. 당일 활동은 종료일이 없다.
 * 종료일이 시작일보다 앞서지 않는다는 규칙은 이 값의 불변식이라 of에서 검증한다.
 */
public record ArchivePeriod(LocalDate startDate, LocalDate endDate) {

    public static ArchivePeriod of(LocalDate startDate, LocalDate endDate) {
        validateOrder(startDate, endDate);
        return new ArchivePeriod(startDate, endDate);
    }

    private static void validateOrder(LocalDate startDate, LocalDate endDate) {
        if (endDate != null && endDate.isBefore(startDate)) {
            throw new BusinessException(ArchiveErrorCode.INVALID_ARCHIVE_PERIOD);
        }
    }
}
  • ArchiveWriteRequest.toCommand()에서 ArchivePeriod.of(startDate, endDate)로 만들어 ArchiveWriteCommand에 담아요(EventCreateRequest.toCommand()가 EventPeriod.of를 부르는 것과 같은 자리)
  • Archive.create·update는 command.period()에서 꺼내 쓰고, validatePeriod는 지워요
  • Archive 필드까지 ArchivePeriod로 바꾸면 #188의 조회 코드(ArchiveDetail, ArchiveSummary, 응답들)가 같이 움직여서, 이번 PR에서는 Command까지만 바꿔도 될 것 같아요

근거는 coding-style.md 2-1절 "의미적으로 묶이는 필드가 여럿이면 VO로 그룹화한다"예요. 지금도 규칙이 메서드 하나에 모여 있어서 그대로 두셔도 괜찮습니다.

if (endDate != null && endDate.isBefore(startDate)) {
throw new BusinessException(ArchiveErrorCode.INVALID_ARCHIVE_PERIOD);
}
}

/**
* 대표 이미지 파일 id. 사진 배열의 첫 장을 대표로 쓴다.
* <p>
* 사진 없는 아카이빙은 등록되지 않는다는 전제지만 최소 1장 제약이 없어, 비어 있으면 예외 대신 null을 돌린다.
* 등록·수정은 사진을 한 장 이상 받지만 그 전에 DB로 직접 넣은 행은 비어 있을 수 있어, 예외 대신 null을 돌린다.
*/
public Long thumbnailFileId() {
return imageIds == null || imageIds.isEmpty() ? null : imageIds.getFirst();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@
@AllArgsConstructor
public enum ArchiveErrorCode implements ErrorCode {

ARCHIVE_NOT_FOUND(ErrorStatus.NOT_FOUND, "아카이빙을 찾을 수 없습니다.");
ARCHIVE_NOT_FOUND(ErrorStatus.NOT_FOUND, "아카이빙을 찾을 수 없습니다."),
INVALID_ARCHIVE_PERIOD(ErrorStatus.BAD_REQUEST, "활동 종료일이 시작일보다 앞설 수 없습니다.");

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