diff --git a/api/admin-api/build.gradle.kts b/api/admin-api/build.gradle.kts
index 983679fe..3d452814 100644
--- a/api/admin-api/build.gradle.kts
+++ b/api/admin-api/build.gradle.kts
@@ -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"))
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
new file mode 100644
index 00000000..fa1d0092
--- /dev/null
+++ b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/archive/AdminArchiveApi.java
@@ -0,0 +1,45 @@
+package kr.ac.kookmin.stream.api.admin.event.archive;
+
+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.ArchiveSearchParams;
+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;
+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.archive.domain.ArchiveErrorCode;
+import org.springdoc.core.annotations.ParameterObject;
+
+/**
+ * 운영진 아카이빙 API의 문서 명세. 구현은 {@link AdminArchiveController}가 맡는다.
+ *
+ * 접근은 {@code ADMIN} role이면 된다. 부서 제한은 두지 않았다.
+ */
+@Tag(name = "아카이빙 관리", description = "운영진 아카이빙 목록·상세 조회")
+public interface AdminArchiveApi {
+
+ /** 아카이빙 목록. 제목으로 검색하고 슬랑제 노출 여부로 거르며, 등록일 최신순으로 페이지 단위 조회한다. */
+ @Operation(summary = "아카이빙 목록 조회",
+ description = "삭제되지 않은 아카이빙을 등록일 최신순으로 페이지 단위로 조회한다. "
+ + "keyword를 주면 제목에 그 글자가 들어 있는 것만, isExposed를 주면 슬랑제 페이지 노출 여부가 "
+ + "그 값인 것만 조회하며 둘을 함께 쓸 수 있다. 생략하면 좁히지 않는다. page는 0부터 시작한다. "
+ + "학생 앱 목록이 활동 시작일 내림차순인 것과 달리 등록일 기준이다.")
+ @ApiErrorCode(type = CommonErrorCode.class, codes = {"INVALID_INPUT"})
+ ApiResponse> getArchives(
+ AdminApiUser apiUser,
+ @ParameterObject ArchiveSearchParams searchParams,
+ @ParameterObject PageParams pageParams
+ );
+
+ /** 아카이빙 상세. 게시물 상세 화면과 수정 화면의 기존 값 채우기에 함께 쓴다. */
+ @Operation(summary = "아카이빙 상세 조회",
+ description = "아카이빙 한 건을 조회한다. 당일 활동은 endDate가 null이다. "
+ + "활동 사진은 노출 순서대로 담고 첫 장이 대표 이미지다 — 대표를 따로 내려주지 않는다. "
+ + "삭제된 파일은 사진 목록에서 조용히 빠진다.")
+ @ApiErrorCode(type = ArchiveErrorCode.class, codes = {"ARCHIVE_NOT_FOUND"})
+ ApiResponse getArchive(AdminApiUser apiUser, Long archiveId);
+}
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
new file mode 100644
index 00000000..cdef0275
--- /dev/null
+++ b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/archive/AdminArchiveController.java
@@ -0,0 +1,63 @@
+package kr.ac.kookmin.stream.api.admin.event.archive;
+
+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.ArchiveSearchParams;
+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;
+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.archive.domain.Archive;
+import kr.ac.kookmin.stream.event.domain.archive.domain.ArchiveDetail;
+import kr.ac.kookmin.stream.event.domain.archive.service.AdminArchiveService;
+import kr.ac.kookmin.stream.event.domain.archive.service.ArchiveService;
+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.GetMapping;
+import org.springframework.web.bind.annotation.ModelAttribute;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+/**
+ * 목록은 정렬 기준이 달라 {@link AdminArchiveService}가, 상세는 학생 앱과 내용이 같아 {@link ArchiveService}가 맡는다
+ * ({@code coding-style.md} 2-7절).
+ */
+@RestController
+@RequestMapping("/v1/admin/archives")
+@RequiredArgsConstructor
+public class AdminArchiveController implements AdminArchiveApi {
+
+ private final AdminArchiveService adminArchiveService;
+ private final ArchiveService archiveService;
+ private final FileService fileService;
+
+ @Override
+ @GetMapping
+ public ApiResponse> getArchives(
+ AdminApiUser apiUser,
+ @Valid @ModelAttribute ArchiveSearchParams searchParams,
+ @Valid @ModelAttribute PageParams pageParams
+ ) {
+ PageResult result = adminArchiveService.getArchives(
+ searchParams.keyword(), searchParams.isExposed(), pageParams.toOffset());
+
+ return ApiResponse.success(PageResponse.from(result, AdminArchiveListItemResponse::from));
+ }
+
+ @Override
+ @GetMapping("/{archiveId}")
+ public ApiResponse getArchive(
+ AdminApiUser apiUser,
+ @PathVariable Long archiveId
+ ) {
+ ArchiveDetail detail = archiveService.getArchive(archiveId);
+ Map imagesById = fileService.findAllByIdIn(detail.imageIds());
+
+ return ApiResponse.success(AdminArchiveDetailResponse.of(detail, imagesById));
+ }
+}
diff --git a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/archive/request/ArchiveSearchParams.java b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/archive/request/ArchiveSearchParams.java
new file mode 100644
index 00000000..c1b09364
--- /dev/null
+++ b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/archive/request/ArchiveSearchParams.java
@@ -0,0 +1,19 @@
+package kr.ac.kookmin.stream.api.admin.event.archive.request;
+
+/**
+ * 아카이빙 목록을 좁히는 조건. 둘 다 없으면 전체를 조회한다.
+ *
+ * {@code Boolean}을 {@code @RequestParam}으로 직접 받지 않고 record로 감싼다. 타입이 맞지 않는 값
+ * ({@code ?isExposed=abc})이 오면 {@code @RequestParam}은 {@code MethodArgumentTypeMismatchException}을
+ * 내는데, {@code GlobalExceptionHandler}가 그 예외를 다루지 않아 catch-all에 걸려 500이 된다.
+ * {@code @ModelAttribute}로 받으면 바인딩 실패가 {@code MethodArgumentNotValidException}으로 나와
+ * 400 {@code INVALID_INPUT}이 된다. {@code LockerPeriodParams}·{@code ArchiveListParams}와 같은 이유다.
+ *
+ * @param keyword 제목 일부. 비어 있으면 제목으로 좁히지 않는다
+ * @param isExposed 슬랑제 페이지 노출 여부. {@code null}이면 노출 여부로 좁히지 않는다
+ */
+public record ArchiveSearchParams(
+ String keyword,
+ Boolean isExposed
+) {
+}
diff --git a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/archive/response/AdminArchiveDetailResponse.java b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/archive/response/AdminArchiveDetailResponse.java
new file mode 100644
index 00000000..9b705f6b
--- /dev/null
+++ b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/archive/response/AdminArchiveDetailResponse.java
@@ -0,0 +1,56 @@
+package kr.ac.kookmin.stream.api.admin.event.archive.response;
+
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+import java.util.List;
+import java.util.Map;
+import kr.ac.kookmin.stream.api.common.StorageUrlBuilder;
+import kr.ac.kookmin.stream.event.domain.archive.domain.ArchiveDetail;
+import kr.ac.kookmin.stream.file.domain.File;
+
+/**
+ * 대표 이미지는 따로 담지 않는다. 사진 배열의 첫 장이 대표라 화면이 그대로 떼어 쓰면 되고, 학생 앱 상세
+ * ({@code ArchiveDetailResponse})도 같은 모양이다.
+ *
+ * @param endDate 당일 활동이면 {@code null}
+ * @param images 활동 사진. 노출 순서대로 담고, 없으면 빈 목록이다. 첫 장이 대표 이미지다
+ */
+public record AdminArchiveDetailResponse(
+ Long archiveId,
+ String title,
+ LocalDate startDate,
+ LocalDate endDate,
+ String location,
+ String departmentName,
+ String content,
+ boolean isExposed,
+ List images,
+ LocalDateTime createdAt
+) {
+
+ /** @param imagesById 활동 사진 파일. 삭제된 파일은 키가 없어 목록에서 조용히 빠진다 */
+ public static AdminArchiveDetailResponse of(ArchiveDetail detail, Map imagesById) {
+ return new AdminArchiveDetailResponse(
+ detail.archiveId(),
+ detail.title(),
+ detail.startDate(),
+ detail.endDate(),
+ detail.location(),
+ detail.departmentName(),
+ detail.content(),
+ detail.exposed(),
+ detail.imageIds().stream()
+ .filter(imagesById::containsKey)
+ .map(fileId -> Image.of(fileId, imagesById.get(fileId)))
+ .toList(),
+ detail.createdAt()
+ );
+ }
+
+ public record Image(Long fileId, String fileUrl) {
+
+ public static Image of(Long fileId, File file) {
+ return new Image(fileId, StorageUrlBuilder.build(file.getFileKey()));
+ }
+ }
+}
diff --git a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/archive/response/AdminArchiveListItemResponse.java b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/archive/response/AdminArchiveListItemResponse.java
new file mode 100644
index 00000000..affc6ee5
--- /dev/null
+++ b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/archive/response/AdminArchiveListItemResponse.java
@@ -0,0 +1,33 @@
+package kr.ac.kookmin.stream.api.admin.event.archive.response;
+
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+import kr.ac.kookmin.stream.event.domain.archive.domain.Archive;
+
+/**
+ * @param endDate 당일 활동이면 {@code null}
+ */
+public record AdminArchiveListItemResponse(
+ Long archiveId,
+ String title,
+ LocalDate startDate,
+ LocalDate endDate,
+ String location,
+ String departmentName,
+ boolean isExposed,
+ LocalDateTime createdAt
+) {
+
+ public static AdminArchiveListItemResponse from(Archive archive) {
+ return new AdminArchiveListItemResponse(
+ archive.getId(),
+ archive.getTitle(),
+ archive.getStartDate(),
+ archive.getEndDate(),
+ archive.getLocation(),
+ archive.getDepartmentName(),
+ archive.isExposed(),
+ archive.getCreatedAt()
+ );
+ }
+}
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 6af7f7a1..1a31352a 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
@@ -1,6 +1,7 @@
package kr.ac.kookmin.stream.event.domain.archive.domain;
import java.time.LocalDate;
+import java.time.LocalDateTime;
import java.util.List;
import lombok.AccessLevel;
import lombok.AllArgsConstructor;
@@ -20,6 +21,10 @@ public class Archive {
private String location;
private String departmentName;
private List imageIds;
+ /** 슬랑제(연말 행사) 전용 페이지에 띄울지. 삭제 여부와 달리 운영진이 목록에서 보고 켜고 끄는 값이라 도메인이 들고 있다. */
+ private boolean exposed;
+ /** 등록 일시. 운영진 목록이 이 순서로 정렬하고 화면에도 보여준다. */
+ private LocalDateTime createdAt;
public static Archive of(
Long id,
@@ -29,9 +34,12 @@ public static Archive of(
LocalDate endDate,
String location,
String departmentName,
- List imageIds
+ List imageIds,
+ boolean exposed,
+ LocalDateTime createdAt
) {
- return new Archive(id, title, content, startDate, endDate, location, departmentName, imageIds);
+ return new Archive(
+ id, title, content, startDate, endDate, location, departmentName, imageIds, exposed, createdAt);
}
/**
diff --git a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/archive/domain/ArchiveDetail.java b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/archive/domain/ArchiveDetail.java
index 3aeb6134..91df9126 100644
--- a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/archive/domain/ArchiveDetail.java
+++ b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/archive/domain/ArchiveDetail.java
@@ -1,10 +1,14 @@
package kr.ac.kookmin.stream.event.domain.archive.domain;
import java.time.LocalDate;
+import java.time.LocalDateTime;
import java.util.List;
/**
* 아카이빙 상세 한 건. 현장 사진은 저장된 배열 순서가 곧 표시 순서다.
+ *
+ * 학생 앱과 운영진이 같이 쓴다. 담는 값이 거의 같고 운영진만 보는 것은 두 개(슬랑제 노출 여부·등록 일시)뿐이라
+ * 하나로 두고, 학생 앱 응답이 그 둘을 담지 않는다.
*/
public record ArchiveDetail(
Long archiveId,
@@ -15,7 +19,9 @@ public record ArchiveDetail(
String departmentName,
String content,
List imageIds,
- List relatedLinks
+ List relatedLinks,
+ boolean exposed,
+ LocalDateTime createdAt
) {
public static ArchiveDetail of(Archive archive, List relatedLinks) {
@@ -28,7 +34,9 @@ public static ArchiveDetail of(Archive archive, List related
archive.getDepartmentName(),
archive.getContent(),
archive.getImageIds() == null ? List.of() : archive.getImageIds(),
- relatedLinks
+ relatedLinks,
+ archive.isExposed(),
+ archive.getCreatedAt()
);
}
}
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 0b5a5c79..66f7e5c9 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
@@ -2,6 +2,8 @@
import java.util.List;
import java.util.Optional;
+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.ArchiveRelatedLink;
@@ -19,6 +21,14 @@ public interface ArchiveRepository {
*/
List findAllYearsDesc();
+ /**
+ * 운영진 목록용. 등록일 최신순(같으면 식별자 내림차순)으로 페이징한다. 학생 앱 목록이 활동 시작일 기준인 것과 다르다.
+ *
+ * @param keyword 제목 일부. null이면 제목으로 좁히지 않는다
+ * @param exposed 슬랑제 노출 여부. null이면 노출 여부로 좁히지 않는다
+ */
+ PageResult search(String keyword, Boolean exposed, PageOffset pageOffset);
+
Optional findById(Long id);
List findRelatedLinksByArchiveId(Long archiveId);
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
new file mode 100644
index 00000000..cd46badb
--- /dev/null
+++ b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/archive/service/AdminArchiveService.java
@@ -0,0 +1,22 @@
+package kr.ac.kookmin.stream.event.domain.archive.service;
+
+import kr.ac.kookmin.stream.common.PageOffset;
+import kr.ac.kookmin.stream.common.PageResult;
+import kr.ac.kookmin.stream.event.domain.archive.domain.Archive;
+
+/**
+ * 운영진 아카이빙 API가 쓰는 서비스.
+ *
+ * 목록만 여기 둔다. 운영진 목록은 등록일 최신순이고 학생 앱 목록은 활동 시작일 내림차순이라 조회 자체가 다르다.
+ * 상세 조회는 내용이 같아 운영진도 {@link ArchiveService}를 그대로 쓴다({@code coding-style.md} 2-7절의 공지와 같다).
+ */
+public interface AdminArchiveService {
+
+ /**
+ * 아카이빙을 등록일 최신순으로 조회한다. 학생 앱 목록이 활동 시작일 기준인 것과 달리, 운영진은 방금 올린 글을 먼저 본다.
+ *
+ * @param keyword 제목 일부. 비어 있으면 제목으로 좁히지 않는다
+ * @param exposed 슬랑제 노출 여부. {@code null}이면 노출 여부로 좁히지 않는다
+ */
+ PageResult getArchives(String keyword, Boolean exposed, PageOffset pageOffset);
+}
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
new file mode 100644
index 00000000..455a1a92
--- /dev/null
+++ b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/archive/service/impl/AdminArchiveServiceImpl.java
@@ -0,0 +1,21 @@
+package kr.ac.kookmin.stream.event.domain.archive.service.impl;
+
+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.repository.ArchiveRepository;
+import kr.ac.kookmin.stream.event.domain.archive.service.AdminArchiveService;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Service;
+
+@Service
+@RequiredArgsConstructor
+class AdminArchiveServiceImpl implements AdminArchiveService {
+
+ private final ArchiveRepository archiveRepository;
+
+ @Override
+ public PageResult getArchives(String keyword, Boolean exposed, PageOffset pageOffset) {
+ return archiveRepository.search(keyword, exposed, pageOffset);
+ }
+}
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 824215fc..abc421cc 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
@@ -8,7 +8,7 @@
import jakarta.persistence.Table;
import java.time.LocalDate;
import java.util.List;
-import kr.ac.kookmin.stream.db.common.BaseTimeEntity;
+import kr.ac.kookmin.stream.db.common.BaseSoftDeleteEntity;
import kr.ac.kookmin.stream.event.domain.archive.domain.Archive;
import lombok.AccessLevel;
import lombok.NoArgsConstructor;
@@ -18,7 +18,7 @@
@Entity
@Table(name = "archives")
@NoArgsConstructor(access = AccessLevel.PROTECTED)
-public class ArchiveJpaEntity extends BaseTimeEntity {
+public class ArchiveJpaEntity extends BaseSoftDeleteEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@@ -46,6 +46,9 @@ public class ArchiveJpaEntity extends BaseTimeEntity {
@Column(name = "image_ids", columnDefinition = "json")
private List imageIds;
+ @Column(name = "is_exposed", nullable = false)
+ private boolean exposed;
+
private ArchiveJpaEntity(Archive archive) {
this.id = archive.getId();
this.title = archive.getTitle();
@@ -55,6 +58,7 @@ private ArchiveJpaEntity(Archive archive) {
this.location = archive.getLocation();
this.departmentName = archive.getDepartmentName();
this.imageIds = archive.getImageIds();
+ this.exposed = archive.isExposed();
}
public static ArchiveJpaEntity from(Archive archive) {
@@ -62,6 +66,8 @@ public static ArchiveJpaEntity from(Archive archive) {
}
public Archive toDomain() {
- return Archive.of(id, title, content, startDate, endDate, location, departmentName, imageIds);
+ return Archive.of(
+ id, title, content, startDate, endDate, location, departmentName, imageIds, exposed,
+ getCreatedAt());
}
}
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 d3ba36c1..8ba7984a 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
@@ -2,6 +2,9 @@
import java.time.LocalDate;
import java.util.List;
+import java.util.Optional;
+import org.springframework.data.domain.Page;
+import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
@@ -17,8 +20,9 @@ public interface ArchiveJpaRepository extends JpaRepository= :startInclusive AND a.startDate < :endExclusive))
+ WHERE a.isDeleted = false
+ AND (:startInclusive IS NULL
+ OR (a.startDate >= :startInclusive AND a.startDate < :endExclusive))
ORDER BY a.startDate DESC, a.id DESC
""")
List findAllInPeriod(
@@ -26,9 +30,36 @@ List findAllInPeriod(
@Param("endExclusive") LocalDate endExclusive
);
+ /** 연도 버튼은 목록과 별도로 조회하므로 삭제 필터를 여기에도 둔다. 빠뜨리면 지운 아카이빙의 연도가 남는다. */
@Query("""
SELECT DISTINCT YEAR(a.startDate) FROM ArchiveJpaEntity a
+ WHERE a.isDeleted = false
ORDER BY YEAR(a.startDate) DESC
""")
List findAllYearsDesc();
+
+ Optional findByIdAndIsDeletedFalse(Long id);
+
+ /**
+ * 운영진 목록. 제목 검색은 {@code LIKE '%...%'}라 인덱스를 타지 못하고 테이블을 훑는다 — 아카이빙이 연간
+ * 수십 건 규모라 그대로 둔다. 검색어 이스케이프는 {@code LikePatterns}가 한다.
+ */
+ @Query(value = """
+ SELECT a FROM ArchiveJpaEntity a
+ WHERE a.isDeleted = false
+ AND (:keyword IS NULL OR a.title LIKE CONCAT('%', :keyword, '%') ESCAPE '!')
+ AND (:exposed IS NULL OR a.exposed = :exposed)
+ ORDER BY a.createdAt DESC, a.id DESC
+ """,
+ countQuery = """
+ SELECT COUNT(a) FROM ArchiveJpaEntity a
+ WHERE a.isDeleted = false
+ AND (:keyword IS NULL OR a.title LIKE CONCAT('%', :keyword, '%') ESCAPE '!')
+ AND (:exposed IS NULL OR a.exposed = :exposed)
+ """)
+ Page search(
+ @Param("keyword") String keyword,
+ @Param("exposed") Boolean exposed,
+ Pageable pageable
+ );
}
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 177ad48d..06f9bec2 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
@@ -3,10 +3,15 @@
import java.util.List;
import java.util.Optional;
import kr.ac.kookmin.stream.common.DateUtil;
+import kr.ac.kookmin.stream.common.PageOffset;
+import kr.ac.kookmin.stream.common.PageResult;
+import kr.ac.kookmin.stream.db.common.LikePatterns;
import kr.ac.kookmin.stream.event.domain.archive.domain.Archive;
import kr.ac.kookmin.stream.event.domain.archive.domain.ArchiveRelatedLink;
import kr.ac.kookmin.stream.event.domain.archive.repository.ArchiveRepository;
import lombok.RequiredArgsConstructor;
+import org.springframework.data.domain.Page;
+import org.springframework.data.domain.PageRequest;
import org.springframework.stereotype.Repository;
@Repository
@@ -33,9 +38,25 @@ public List findAllYearsDesc() {
return archiveJpaRepository.findAllYearsDesc();
}
+ @Override
+ public PageResult search(String keyword, Boolean exposed, PageOffset pageOffset) {
+ Page result = archiveJpaRepository.search(
+ LikePatterns.escape(keyword),
+ exposed,
+ PageRequest.of(pageOffset.page(), pageOffset.size())
+ );
+
+ return PageResult.of(
+ result.getContent().stream().map(ArchiveJpaEntity::toDomain).toList(),
+ result.getNumber(),
+ result.getSize(),
+ result.getTotalElements()
+ );
+ }
+
@Override
public Optional findById(Long id) {
- return archiveJpaRepository.findById(id).map(ArchiveJpaEntity::toDomain);
+ return archiveJpaRepository.findByIdAndIsDeletedFalse(id).map(ArchiveJpaEntity::toDomain);
}
@Override
diff --git a/infrastructure/db/src/main/resources/db/migration/V30__add_exposed_and_is_deleted_to_archives.sql b/infrastructure/db/src/main/resources/db/migration/V30__add_exposed_and_is_deleted_to_archives.sql
new file mode 100644
index 00000000..f0e9dc46
--- /dev/null
+++ b/infrastructure/db/src/main/resources/db/migration/V30__add_exposed_and_is_deleted_to_archives.sql
@@ -0,0 +1,12 @@
+-- 관리자 API가 아카이빙을 목록에서 걸러내고 삭제할 수 있게 한다.
+--
+-- 노출 여부: 슬랑제(연말 행사) 전용 페이지에 이 아카이빙을 띄울지. 운영진이 목록에서 켜고 끈다.
+-- 아카이빙이 노출될 곳은 그 페이지뿐이라 컬럼 이름에는 슬랑제를 넣지 않는다.
+-- 삭제: 실수로 지운 아카이빙을 되살릴 수 있도록 행은 남긴다 (flyway-migration.md 3-4절).
+-- 운영 중인 행이 있어 둘 다 DEFAULT를 준다(3-3절). 기존 아카이빙은 "노출 안 함 · 살아 있음"으로 시작한다.
+--
+-- 인덱스는 두지 않는다. 아카이빙은 연간 수십 건 규모라 목록이 테이블을 통째로 읽어도 되고,
+-- 제목 검색이 LIKE '%...%'라 어차피 인덱스를 타지 못한다.
+ALTER TABLE archives
+ ADD COLUMN is_exposed TINYINT(1) NOT NULL DEFAULT 0,
+ ADD COLUMN is_deleted TINYINT(1) NOT NULL DEFAULT 0;