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
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,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}가 맡는다.
* <p>
* 접근은 {@code ADMIN} role이면 된다. 부서 제한은 두지 않았다.
*/
@Tag(name = "아카이빙 관리", description = "운영진 아카이빙 목록·상세 조회")
public interface AdminArchiveApi {

/** 아카이빙 목록. 제목으로 검색하고 슬랑제 노출 여부로 거르며, 등록일 최신순으로 페이지 단위 조회한다. */
@Operation(summary = "아카이빙 목록 조회",
description = "삭제되지 않은 아카이빙을 등록일 최신순으로 페이지 단위로 조회한다. "
+ "keyword를 주면 제목에 그 글자가 들어 있는 것만, isExposed를 주면 슬랑제 페이지 노출 여부가 "
+ "그 값인 것만 조회하며 둘을 함께 쓸 수 있다. 생략하면 좁히지 않는다. page는 0부터 시작한다. "
+ "학생 앱 목록이 활동 시작일 내림차순인 것과 달리 등록일 기준이다.")
@ApiErrorCode(type = CommonErrorCode.class, codes = {"INVALID_INPUT"})
ApiResponse<PageResponse<AdminArchiveListItemResponse>> getArchives(
AdminApiUser apiUser,
@ParameterObject ArchiveSearchParams searchParams,
@ParameterObject PageParams pageParams
);

/** 아카이빙 상세. 게시물 상세 화면과 수정 화면의 기존 값 채우기에 함께 쓴다. */
@Operation(summary = "아카이빙 상세 조회",
description = "아카이빙 한 건을 조회한다. 당일 활동은 endDate가 null이다. "
+ "활동 사진은 노출 순서대로 담고 첫 장이 대표 이미지다 — 대표를 따로 내려주지 않는다. "
+ "삭제된 파일은 사진 목록에서 조용히 빠진다.")
@ApiErrorCode(type = ArchiveErrorCode.class, codes = {"ARCHIVE_NOT_FOUND"})
ApiResponse<AdminArchiveDetailResponse> getArchive(AdminApiUser apiUser, Long archiveId);
}
Original file line number Diff line number Diff line change
@@ -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<PageResponse<AdminArchiveListItemResponse>> getArchives(
AdminApiUser apiUser,
@Valid @ModelAttribute ArchiveSearchParams searchParams,
@Valid @ModelAttribute PageParams pageParams
) {
PageResult<Archive> result = adminArchiveService.getArchives(
searchParams.keyword(), searchParams.isExposed(), pageParams.toOffset());

return ApiResponse.success(PageResponse.from(result, AdminArchiveListItemResponse::from));
}

@Override
@GetMapping("/{archiveId}")
public ApiResponse<AdminArchiveDetailResponse> getArchive(
AdminApiUser apiUser,
@PathVariable Long archiveId
) {
ArchiveDetail detail = archiveService.getArchive(archiveId);
Map<Long, File> imagesById = fileService.findAllByIdIn(detail.imageIds());

return ApiResponse.success(AdminArchiveDetailResponse.of(detail, imagesById));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
package kr.ac.kookmin.stream.api.admin.event.archive.request;

/**
* 아카이빙 목록을 좁히는 조건. 둘 다 없으면 전체를 조회한다.
* <p>
* {@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
) {
}
Original file line number Diff line number Diff line change
@@ -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<Image> images,
LocalDateTime createdAt
) {

/** @param imagesById 활동 사진 파일. 삭제된 파일은 키가 없어 목록에서 조용히 빠진다 */
public static AdminArchiveDetailResponse of(ArchiveDetail detail, Map<Long, File> 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()));
}
}
}
Original file line number Diff line number Diff line change
@@ -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()
);
}
}
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -20,6 +21,10 @@ public class Archive {
private String location;
private String departmentName;
private List<Long> imageIds;
/** 슬랑제(연말 행사) 전용 페이지에 띄울지. 삭제 여부와 달리 운영진이 목록에서 보고 켜고 끄는 값이라 도메인이 들고 있다. */
private boolean exposed;
/** 등록 일시. 운영진 목록이 이 순서로 정렬하고 화면에도 보여준다. */
private LocalDateTime createdAt;

public static Archive of(
Long id,
Expand All @@ -29,9 +34,12 @@ public static Archive of(
LocalDate endDate,
String location,
String departmentName,
List<Long> imageIds
List<Long> 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);
}

/**
Expand Down
Original file line number Diff line number Diff line change
@@ -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;

/**
* 아카이빙 상세 한 건. 현장 사진은 저장된 배열 순서가 곧 표시 순서다.
* <p>
* 학생 앱과 운영진이 같이 쓴다. 담는 값이 거의 같고 운영진만 보는 것은 두 개(슬랑제 노출 여부·등록 일시)뿐이라
* 하나로 두고, 학생 앱 응답이 그 둘을 담지 않는다.
*/
public record ArchiveDetail(
Long archiveId,
Expand All @@ -15,7 +19,9 @@ public record ArchiveDetail(
String departmentName,
String content,
List<Long> imageIds,
List<ArchiveRelatedLink> relatedLinks
List<ArchiveRelatedLink> relatedLinks,
boolean exposed,
LocalDateTime createdAt
) {

public static ArchiveDetail of(Archive archive, List<ArchiveRelatedLink> relatedLinks) {
Expand All @@ -28,7 +34,9 @@ public static ArchiveDetail of(Archive archive, List<ArchiveRelatedLink> related
archive.getDepartmentName(),
archive.getContent(),
archive.getImageIds() == null ? List.of() : archive.getImageIds(),
relatedLinks
relatedLinks,
archive.isExposed(),
archive.getCreatedAt()
);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -19,6 +21,14 @@ public interface ArchiveRepository {
*/
List<Integer> findAllYearsDesc();

/**
* 운영진 목록용. 등록일 최신순(같으면 식별자 내림차순)으로 페이징한다. 학생 앱 목록이 활동 시작일 기준인 것과 다르다.
*
* @param keyword 제목 일부. null이면 제목으로 좁히지 않는다
* @param exposed 슬랑제 노출 여부. null이면 노출 여부로 좁히지 않는다
*/
PageResult<Archive> search(String keyword, Boolean exposed, PageOffset pageOffset);

Optional<Archive> findById(Long id);

List<ArchiveRelatedLink> findRelatedLinksByArchiveId(Long archiveId);
Expand Down
Original file line number Diff line number Diff line change
@@ -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가 쓰는 서비스.
* <p>
* 목록만 여기 둔다. 운영진 목록은 등록일 최신순이고 학생 앱 목록은 활동 시작일 내림차순이라 조회 자체가 다르다.
* 상세 조회는 내용이 같아 운영진도 {@link ArchiveService}를 그대로 쓴다({@code coding-style.md} 2-7절의 공지와 같다).
*/
public interface AdminArchiveService {

/**
* 아카이빙을 등록일 최신순으로 조회한다. 학생 앱 목록이 활동 시작일 기준인 것과 달리, 운영진은 방금 올린 글을 먼저 본다.
*
* @param keyword 제목 일부. 비어 있으면 제목으로 좁히지 않는다
* @param exposed 슬랑제 노출 여부. {@code null}이면 노출 여부로 좁히지 않는다
*/
PageResult<Archive> getArchives(String keyword, Boolean exposed, PageOffset pageOffset);
}
Original file line number Diff line number Diff line change
@@ -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<Archive> getArchives(String keyword, Boolean exposed, PageOffset pageOffset) {
return archiveRepository.search(keyword, exposed, pageOffset);
}
}
Loading