diff --git a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/archive/AdminArchiveApi.java b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/archive/AdminArchiveApi.java
index 6718116bb..ec9efd972 100644
--- a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/archive/AdminArchiveApi.java
+++ b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/archive/AdminArchiveApi.java
@@ -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;
@@ -12,6 +14,8 @@
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;
/**
@@ -19,7 +23,7 @@
*
* 접근은 {@code ADMIN} role이면 된다. 부서 제한은 두지 않았다.
*/
-@Tag(name = "아카이빙 관리", description = "운영진 아카이빙 목록·상세 조회")
+@Tag(name = "아카이빙 관리", description = "운영진 아카이빙 목록·상세 조회와 등록·수정·삭제")
public interface AdminArchiveApi {
/** 아카이빙 목록. 제목으로 검색하고 슬랑제 노출 여부로 거르며, 등록일 최신순으로 페이지 단위 조회한다. */
@@ -44,4 +48,40 @@ ApiResponse> getArchives(
+ "연결하지 않았거나 행사가 삭제됐으면 null이다 — 운영진 화면이므로 아직 게시하지 않은 행사도 보여준다.")
@ApiErrorCode(type = ArchiveErrorCode.class, codes = {"ARCHIVE_NOT_FOUND"})
ApiResponse 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 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 updateArchive(AdminApiUser apiUser, Long archiveId, ArchiveWriteRequest request);
+
+ @Operation(summary = "아카이빙 삭제",
+ description = "아카이빙을 삭제한다. 행은 남기고 표시만 바꿔 되살릴 수 있게 하며, 학생 앱 조회에서는 바로 빠진다. "
+ + "이미 삭제된 아카이빙은 없는 것으로 본다.")
+ @ApiErrorCode(type = ArchiveErrorCode.class, codes = {"ARCHIVE_NOT_FOUND"})
+ ApiResponse 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 changeExposure(AdminApiUser apiUser, ArchiveExposureChangeRequest request);
}
diff --git a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/archive/AdminArchiveController.java b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/archive/AdminArchiveController.java
index c14438290..f60e70dd3 100644
--- a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/archive/AdminArchiveController.java
+++ b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/archive/AdminArchiveController.java
@@ -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;
@@ -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;
@@ -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
@@ -61,4 +70,42 @@ public ApiResponse getArchive(
return ApiResponse.success(
AdminArchiveDetailResponse.of(archive.detail(), archive.relatedEventTitle(), imagesById));
}
+
+ @Override
+ @PostMapping
+ public ApiResponse createArchive(
+ AdminApiUser apiUser,
+ @Valid @RequestBody ArchiveWriteRequest request
+ ) {
+ adminArchiveWriteUseCase.create(request.toCommand());
+ return ApiResponse.success();
+ }
+
+ @Override
+ @PutMapping("/{archiveId}")
+ public ApiResponse updateArchive(
+ AdminApiUser apiUser,
+ @PathVariable Long archiveId,
+ @Valid @RequestBody ArchiveWriteRequest request
+ ) {
+ adminArchiveWriteUseCase.update(archiveId, request.toCommand());
+ return ApiResponse.success();
+ }
+
+ @Override
+ @DeleteMapping("/{archiveId}")
+ public ApiResponse deleteArchive(AdminApiUser apiUser, @PathVariable Long archiveId) {
+ adminArchiveService.delete(archiveId);
+ return ApiResponse.success();
+ }
+
+ @Override
+ @PatchMapping("/exposure")
+ public ApiResponse changeExposure(
+ AdminApiUser apiUser,
+ @Valid @RequestBody ArchiveExposureChangeRequest request
+ ) {
+ adminArchiveService.changeExposure(request.archiveIds(), request.isExposed());
+ return ApiResponse.success();
+ }
}
diff --git a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/archive/request/ArchiveExposureChangeRequest.java b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/archive/request/ArchiveExposureChangeRequest.java
new file mode 100644
index 000000000..e15dc6281
--- /dev/null
+++ b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/archive/request/ArchiveExposureChangeRequest.java
@@ -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;
+
+/**
+ * 여러 아카이빙의 슬랑제 페이지 노출 여부를 한 번에 바꾼다.
+ *
+ * 목록의 체크박스 일괄 변경과 행별 드롭다운 변경이 같은 동작이라 하나로 받는다 — 행별 변경은 식별자가 하나인 요청이다.
+ */
+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
+) {
+}
diff --git a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/archive/request/ArchiveWriteRequest.java b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/archive/request/ArchiveWriteRequest.java
new file mode 100644
index 000000000..828d43361
--- /dev/null
+++ b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/archive/request/ArchiveWriteRequest.java
@@ -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로 나누면 같은 선언이 두 벌 생긴다.
+ *
+ * 종료일이 시작일보다 앞서는지는 여기서 보지 않는다. 값 하나의 형식이 아니라 두 값 사이의 규칙이라
+ * {@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 = "활동 내용을 입력해 주세요.")
+ @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 = "활동 사진을 한 장 이상 등록해 주세요.")
+ 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 distinctImageIds() {
+ return imageIds.stream().distinct().toList();
+ }
+}
diff --git a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/archive/usecase/AdminArchiveWriteUseCase.java b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/archive/usecase/AdminArchiveWriteUseCase.java
new file mode 100644
index 000000000..e9685d027
--- /dev/null
+++ b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/archive/usecase/AdminArchiveWriteUseCase.java
@@ -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절).
+ *
+ * 확인과 저장을 한 트랜잭션으로 묶지 않는다. 확인 직후 사진이 지워져도 상세 조회가 목록에서 조용히 빼고, 행사가
+ * 지워지면 연결만 비어 보인다 — 둘 다 아카이빙을 못 쓰게 만들지 않는다. 공지와 같은 판단이다
+ * ({@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 imageIds) {
+ Set 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);
+ }
+ }
+}
diff --git a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/archive/domain/Archive.java b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/archive/domain/Archive.java
index 8c718288a..8ecc22007 100644
--- a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/archive/domain/Archive.java
+++ b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/archive/domain/Archive.java
@@ -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;
@@ -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;
+ }
+
+ /**
+ * 당일 활동은 종료일이 없다. 종료일을 줬다면 시작일보다 앞설 수 없다.
+ *
+ * 값 하나의 형식이 아니라 두 값 사이의 규칙이라 요청 어노테이션으로는 표현할 수 없어 도메인에서 본다.
+ */
+ private static void validatePeriod(LocalDate startDate, LocalDate endDate) {
+ if (endDate != null && endDate.isBefore(startDate)) {
+ throw new BusinessException(ArchiveErrorCode.INVALID_ARCHIVE_PERIOD);
+ }
+ }
+
/**
* 대표 이미지 파일 id. 사진 배열의 첫 장을 대표로 쓴다.
*
- * 사진 없는 아카이빙은 등록되지 않는다는 전제지만 최소 1장 제약이 없어, 비어 있으면 예외 대신 null을 돌린다.
+ * 등록·수정은 사진을 한 장 이상 받지만 그 전에 DB로 직접 넣은 행은 비어 있을 수 있어, 예외 대신 null을 돌린다.
*/
public Long thumbnailFileId() {
return imageIds == null || imageIds.isEmpty() ? null : imageIds.getFirst();
diff --git a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/archive/domain/ArchiveErrorCode.java b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/archive/domain/ArchiveErrorCode.java
index 9561f9bdc..65c5288fb 100644
--- a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/archive/domain/ArchiveErrorCode.java
+++ b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/archive/domain/ArchiveErrorCode.java
@@ -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;
diff --git a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/archive/domain/ArchiveWriteCommand.java b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/archive/domain/ArchiveWriteCommand.java
new file mode 100644
index 000000000..025db7497
--- /dev/null
+++ b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/archive/domain/ArchiveWriteCommand.java
@@ -0,0 +1,24 @@
+package kr.ac.kookmin.stream.event.domain.archive.domain;
+
+import java.time.LocalDate;
+import java.util.List;
+
+/**
+ * 아카이빙 등록·수정 입력. 수정이 전체 필드를 통째로 바꾸는 방식이라 등록과 담는 값이 같아 하나로 둔다.
+ *
+ * @param endDate 당일 활동이면 {@code null}
+ * @param imageIds 활동 사진. 배열 순서가 노출 순서이고 첫 장이 대표 이미지다
+ * @param relatedEventId 연결할 행사. 연결하지 않으면 {@code null}이고, 수정에서 {@code null}이면 연결을 끊는다
+ */
+public record ArchiveWriteCommand(
+ String title,
+ String content,
+ LocalDate startDate,
+ LocalDate endDate,
+ String location,
+ String departmentName,
+ List imageIds,
+ boolean exposed,
+ Long relatedEventId
+) {
+}
diff --git a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/archive/repository/ArchiveRepository.java b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/archive/repository/ArchiveRepository.java
index 638c21354..0ec1808e9 100644
--- a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/archive/repository/ArchiveRepository.java
+++ b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/archive/repository/ArchiveRepository.java
@@ -29,4 +29,19 @@ public interface ArchiveRepository {
PageResult search(String keyword, Boolean exposed, PageOffset pageOffset);
Optional findById(Long id);
+
+ /**
+ * 식별자 목록에 드는 아카이빙. 삭제된 것은 담지 않는다. 노출 여부 일괄 변경이 요청한 전부가 살아 있는지
+ * 확인하려고 쓰므로, 결과 수가 요청 수와 다르면 부르는 쪽이 끊는다.
+ */
+ List findAllByIds(List archiveIds);
+
+ Archive save(Archive archive);
+
+ void saveAll(List archives);
+
+ /**
+ * 소프트 삭제한다. 이미 없거나 지워진 아카이빙이면 {@code false}다.
+ */
+ boolean delete(Long id);
}
diff --git a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/archive/service/AdminArchiveService.java b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/archive/service/AdminArchiveService.java
index cd46badb1..ceae56b28 100644
--- a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/archive/service/AdminArchiveService.java
+++ b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/archive/service/AdminArchiveService.java
@@ -1,8 +1,10 @@
package kr.ac.kookmin.stream.event.domain.archive.service;
+import java.util.List;
import kr.ac.kookmin.stream.common.PageOffset;
import kr.ac.kookmin.stream.common.PageResult;
import kr.ac.kookmin.stream.event.domain.archive.domain.Archive;
+import kr.ac.kookmin.stream.event.domain.archive.domain.ArchiveWriteCommand;
/**
* 운영진 아카이빙 API가 쓰는 서비스.
@@ -19,4 +21,24 @@ public interface AdminArchiveService {
* @param exposed 슬랑제 노출 여부. {@code null}이면 노출 여부로 좁히지 않는다
*/
PageResult getArchives(String keyword, Boolean exposed, PageOffset pageOffset);
+
+ /**
+ * 사진 파일과 연결할 행사가 실제로 있는지는 이 서비스가 보지 않는다. 교차 도메인이라
+ * {@code api:admin-api}의 UseCase가 확인한다.
+ */
+ void create(ArchiveWriteCommand command);
+
+ /** 사진과 연결 행사도 받은 값으로 교체한다. 삭제된 아카이빙은 없는 것으로 본다. */
+ void update(Long archiveId, ArchiveWriteCommand command);
+
+ /** 행은 남기고 표시만 바꾼다. 학생 앱 조회에서는 바로 빠진다. */
+ void delete(Long archiveId);
+
+ /**
+ * 여러 아카이빙의 슬랑제 페이지 노출 여부를 한 번에 바꾼다. 목록의 체크박스 일괄 변경과 행별 드롭다운 변경이
+ * 같은 동작이라 하나로 받는다 — 행별 변경은 식별자가 하나인 요청이다.
+ *
+ * 하나라도 없거나 지워진 아카이빙이 섞이면 아무것도 바꾸지 않는다.
+ */
+ void changeExposure(List archiveIds, boolean exposed);
}
diff --git a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/archive/service/impl/AdminArchiveServiceImpl.java b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/archive/service/impl/AdminArchiveServiceImpl.java
index 455a1a92f..cc268faef 100644
--- a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/archive/service/impl/AdminArchiveServiceImpl.java
+++ b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/archive/service/impl/AdminArchiveServiceImpl.java
@@ -1,12 +1,17 @@
package kr.ac.kookmin.stream.event.domain.archive.service.impl;
+import java.util.List;
+import kr.ac.kookmin.stream.common.BusinessException;
import kr.ac.kookmin.stream.common.PageOffset;
import kr.ac.kookmin.stream.common.PageResult;
import kr.ac.kookmin.stream.event.domain.archive.domain.Archive;
+import kr.ac.kookmin.stream.event.domain.archive.domain.ArchiveErrorCode;
+import kr.ac.kookmin.stream.event.domain.archive.domain.ArchiveWriteCommand;
import kr.ac.kookmin.stream.event.domain.archive.repository.ArchiveRepository;
import kr.ac.kookmin.stream.event.domain.archive.service.AdminArchiveService;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
@Service
@RequiredArgsConstructor
@@ -18,4 +23,47 @@ class AdminArchiveServiceImpl implements AdminArchiveService {
public PageResult getArchives(String keyword, Boolean exposed, PageOffset pageOffset) {
return archiveRepository.search(keyword, exposed, pageOffset);
}
+
+ @Override
+ @Transactional
+ public void create(ArchiveWriteCommand command) {
+ archiveRepository.save(Archive.create(command));
+ }
+
+ @Override
+ @Transactional
+ public void update(Long archiveId, ArchiveWriteCommand command) {
+ Archive archive = getArchive(archiveId);
+ archive.update(command);
+
+ archiveRepository.save(archive);
+ }
+
+ @Override
+ @Transactional
+ public void delete(Long archiveId) {
+ if (!archiveRepository.delete(archiveId)) {
+ throw new BusinessException(ArchiveErrorCode.ARCHIVE_NOT_FOUND);
+ }
+ }
+
+ @Override
+ @Transactional
+ public void changeExposure(List archiveIds, boolean exposed) {
+ // 같은 식별자가 두 번 오면 아래 개수 비교가 어긋나므로, 호출부에 맡기지 않고 여기서 걷어낸다
+ List distinctIds = archiveIds.stream().distinct().toList();
+ List archives = archiveRepository.findAllByIds(distinctIds);
+ // 하나라도 없으면 아무것도 바꾸지 않는다. 삭제된 아카이빙은 findAllByIds가 빼므로 수가 모자란다
+ if (archives.size() != distinctIds.size()) {
+ throw new BusinessException(ArchiveErrorCode.ARCHIVE_NOT_FOUND);
+ }
+
+ archives.forEach(archive -> archive.changeExposure(exposed));
+ archiveRepository.saveAll(archives);
+ }
+
+ private Archive getArchive(Long archiveId) {
+ return archiveRepository.findById(archiveId)
+ .orElseThrow(() -> new BusinessException(ArchiveErrorCode.ARCHIVE_NOT_FOUND));
+ }
}
diff --git a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/ArchiveJpaEntity.java b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/ArchiveJpaEntity.java
index 0d19036f4..eeebc014a 100644
--- a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/ArchiveJpaEntity.java
+++ b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/ArchiveJpaEntity.java
@@ -12,10 +12,16 @@
import kr.ac.kookmin.stream.event.domain.archive.domain.Archive;
import lombok.AccessLevel;
import lombok.NoArgsConstructor;
+import org.hibernate.annotations.DynamicUpdate;
import org.hibernate.annotations.JdbcTypeCode;
import org.hibernate.type.SqlTypes;
+// 바뀐 컬럼만 UPDATE한다(@DynamicUpdate). 수정은 같은 트랜잭션에서 findById로 엔티티를 영속성 컨텍스트에 올린 뒤
+// merge하므로, 도메인이 들고 있지 않은 is_deleted는 읽어 둔 값 그대로여서 변경으로 잡히지 않고 UPDATE에서 빠진다.
+// 덕분에 운영진 A가 수정하는 사이 B가 삭제를 커밋해도 A의 UPDATE가 삭제를 되돌리지 않는다.
+// (NoticeJpaEntity·LockerJpaEntity가 같은 이유로 붙이고 있다)
@Entity
+@DynamicUpdate
@Table(name = "archives")
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class ArchiveJpaEntity extends BaseSoftDeleteEntity {
diff --git a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/ArchiveJpaRepository.java b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/ArchiveJpaRepository.java
index 8ba7984ad..6a702d652 100644
--- a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/ArchiveJpaRepository.java
+++ b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/ArchiveJpaRepository.java
@@ -40,6 +40,8 @@ ORDER BY YEAR(a.startDate) DESC
Optional findByIdAndIsDeletedFalse(Long id);
+ List findAllByIdInAndIsDeletedFalse(List ids);
+
/**
* 운영진 목록. 제목 검색은 {@code LIKE '%...%'}라 인덱스를 타지 못하고 테이블을 훑는다 — 아카이빙이 연간
* 수십 건 규모라 그대로 둔다. 검색어 이스케이프는 {@code LikePatterns}가 한다.
diff --git a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/ArchiveRepositoryImpl.java b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/ArchiveRepositoryImpl.java
index 0b463851e..bfd37943d 100644
--- a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/ArchiveRepositoryImpl.java
+++ b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/ArchiveRepositoryImpl.java
@@ -56,4 +56,38 @@ public PageResult search(String keyword, Boolean exposed, PageOffset pa
public Optional findById(Long id) {
return archiveJpaRepository.findByIdAndIsDeletedFalse(id).map(ArchiveJpaEntity::toDomain);
}
+
+ @Override
+ public List findAllByIds(List archiveIds) {
+ if (archiveIds.isEmpty()) {
+ return List.of();
+ }
+ return archiveJpaRepository.findAllByIdInAndIsDeletedFalse(archiveIds).stream()
+ .map(ArchiveJpaEntity::toDomain)
+ .toList();
+ }
+
+ @Override
+ public Archive save(Archive archive) {
+ return archiveJpaRepository.save(ArchiveJpaEntity.from(archive)).toDomain();
+ }
+
+ @Override
+ public void saveAll(List archives) {
+ archiveJpaRepository.saveAll(archives.stream().map(ArchiveJpaEntity::from).toList());
+ }
+
+ /**
+ * 관리 상태 엔티티를 읽어 표시만 바꾼다. 더티 체킹이 is_deleted 한 컬럼만 UPDATE하므로, 도메인이 들고 있지 않은
+ * 삭제 표시를 쓰기 경로로 되돌릴 일이 없다 (NoticeRepositoryImpl.delete와 같은 방식).
+ */
+ @Override
+ public boolean delete(Long id) {
+ return archiveJpaRepository.findByIdAndIsDeletedFalse(id)
+ .map(entity -> {
+ entity.delete();
+ return true;
+ })
+ .orElse(false);
+ }
}