Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
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
Expand Up @@ -4,51 +4,65 @@
import io.swagger.v3.oas.annotations.tags.Tag;
import kr.ac.kookmin.stream.api.admin.AdminApiUser;
import kr.ac.kookmin.stream.api.admin.welfare.notice.request.NoticeCreateRequest;
import kr.ac.kookmin.stream.api.admin.welfare.notice.request.NoticePinRequest;
import kr.ac.kookmin.stream.api.admin.welfare.notice.request.NoticeUpdateRequest;
import kr.ac.kookmin.stream.api.admin.welfare.notice.response.AdminNoticeListItemResponse;
import kr.ac.kookmin.stream.api.admin.welfare.notice.response.NoticeCreateResponse;
import kr.ac.kookmin.stream.api.admin.welfare.notice.response.NoticeUpdateResponse;
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.file.domain.FileErrorCode;
import kr.ac.kookmin.stream.welfare.domain.notice.domain.NoticeErrorCode;
import org.springdoc.core.annotations.ParameterObject;

/**
* 운영진 공지 API의 문서 명세. 구현은 {@link AdminNoticeController}가 맡는다.
* <p>
* 접근은 {@code ADMIN} role이면 된다. 부서 제한은 두지 않았다.
* <p>
* 목록은 이 API가 제공한다(제목 검색, 페이지 번호 이동). 상세 조회는 학생 앱 공지 API({@code /v1/app/notices/{noticeId}})를
* 그대로 쓴다. 관리자 토큰도 {@code STUDENT} 권한을 가지므로 호출할 수 있고, 상세 응답이 수정 화면에 필요한 고정 여부를
* 함께 내려준다.
*/
@Tag(name = "공지 관리", description = "운영진 공지 작성·수정·삭제·고정")
@Tag(name = "공지 관리", description = "운영진 공지 목록 조회·작성·수정·삭제")
public interface AdminNoticeApi {

/** 공지 목록. 종류로 거르고 제목으로 검색하며, 고정 공지를 먼저, 그다음 최신순으로 페이지 단위 조회한다. */
@Operation(summary = "공지 목록 조회",
description = "삭제되지 않은 공지를 고정 공지 우선, 최신순으로 페이지 단위로 조회한다. category(GENERAL, PARTNERSHIP)를 "
+ "주면 그 종류만, keyword를 주면 제목에 그 글자가 들어 있는 공지만 조회하며 둘을 함께 쓸 수 있다. "
+ "page는 0부터 시작한다.")
@ApiErrorCode(type = CommonErrorCode.class, codes = {"INVALID_INPUT"})
ApiResponse<PageResponse<AdminNoticeListItemResponse>> getNotices(
AdminApiUser apiUser,
String category,
String keyword,
@ParameterObject PageParams pageParams
);

/** 공지 작성. 작성자는 토큰의 관리자로 정해진다. */
@Operation(summary = "공지 작성",
description = "공지를 등록한다. 이미지·첨부 파일은 업로드를 마친 파일 ID로 넘기며, 존재하지 않는 파일이 하나라도 있으면 "
+ "등록하지 않는다. 새 공지는 고정되지 않은 상태로 만들어진다.")
+ "등록하지 않는다. 고정 여부(pinned)도 함께 정한다.")
@ApiErrorCode(type = CommonErrorCode.class, codes = {"INVALID_INPUT"})
@ApiErrorCode(type = FileErrorCode.class, codes = {"FILE_NOT_FOUND"})
ApiResponse<NoticeCreateResponse> createNotice(AdminApiUser apiUser, NoticeCreateRequest request);

/** 공지 수정. 요청의 수정 시각이 저장된 값과 다르면 거부한다. */
/** 공지 수정. 두 관리자가 겹쳐 수정하면 나중에 저장한 내용이 반영된다. */
@Operation(summary = "공지 수정",
description = "공지의 제목·본문·종류·이미지·첨부 파일을 통째로 바꾼다. 수정 화면이 공지를 읽은 뒤 다른 관리자가 먼저 "
+ "고쳤다면(수정 시각 불일치) 409로 거부한다.")
description = "공지의 제목·본문·종류·이미지·첨부 파일·고정 여부를 통째로 바꾼다. 수정 화면은 공지 상세 조회"
+ "(GET /v1/app/notices/{noticeId})로 채운다. 다른 관리자가 먼저 고쳤는지는 확인하지 않으므로 나중에 저장한 "
+ "내용이 그대로 반영된다.")
@ApiErrorCode(type = CommonErrorCode.class, codes = {"INVALID_INPUT"})
@ApiErrorCode(type = FileErrorCode.class, codes = {"FILE_NOT_FOUND"})
@ApiErrorCode(type = NoticeErrorCode.class, codes = {"NOTICE_NOT_FOUND", "NOTICE_UPDATE_CONFLICT"})
@ApiErrorCode(type = NoticeErrorCode.class, codes = {"NOTICE_NOT_FOUND"})
ApiResponse<NoticeUpdateResponse> updateNotice(AdminApiUser apiUser, Long noticeId, NoticeUpdateRequest request);

/** 공지 삭제. 소프트 삭제라 학생 앱 목록·상세에서 보이지 않게 된다. */
@Operation(summary = "공지 삭제",
description = "공지를 삭제한다. 데이터는 남기고 학생 앱에서만 보이지 않게 한다. 연결된 파일은 지우지 않는다.")
@ApiErrorCode(type = NoticeErrorCode.class, codes = {"NOTICE_NOT_FOUND"})
ApiResponse<Void> deleteNotice(AdminApiUser apiUser, Long noticeId);

/** 공지 상단 고정 설정/해제. */
@Operation(summary = "공지 고정 설정/해제",
description = "공지를 목록 맨 위에 고정하거나 고정을 해제한다. 이미 그 상태여도 성공으로 응답한다.")
@ApiErrorCode(type = CommonErrorCode.class, codes = {"INVALID_INPUT"})
@ApiErrorCode(type = NoticeErrorCode.class, codes = {"NOTICE_NOT_FOUND"})
ApiResponse<Void> changePinned(AdminApiUser apiUser, Long noticeId, NoticePinRequest request);
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,22 +3,28 @@
import jakarta.validation.Valid;
import kr.ac.kookmin.stream.api.admin.AdminApiUser;
import kr.ac.kookmin.stream.api.admin.welfare.notice.request.NoticeCreateRequest;
import kr.ac.kookmin.stream.api.admin.welfare.notice.request.NoticePinRequest;
import kr.ac.kookmin.stream.api.admin.welfare.notice.request.NoticeUpdateRequest;
import kr.ac.kookmin.stream.api.admin.welfare.notice.response.AdminNoticeListItemResponse;
import kr.ac.kookmin.stream.api.admin.welfare.notice.response.NoticeCreateResponse;
import kr.ac.kookmin.stream.api.admin.welfare.notice.response.NoticeUpdateResponse;
import kr.ac.kookmin.stream.api.admin.welfare.notice.usecase.AdminNoticeWriteUseCase;
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.welfare.domain.notice.domain.Notice;
import kr.ac.kookmin.stream.welfare.domain.notice.domain.NoticeCategory;
import kr.ac.kookmin.stream.welfare.domain.notice.service.NoticeService;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.PatchMapping;
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.PutMapping;
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
Expand All @@ -29,6 +35,18 @@ public class AdminNoticeController implements AdminNoticeApi {
private final NoticeService noticeService;
private final AdminNoticeWriteUseCase adminNoticeWriteUseCase;

@Override
@GetMapping
public ApiResponse<PageResponse<AdminNoticeListItemResponse>> getNotices(
AdminApiUser apiUser,
@RequestParam(required = false) String category,
@RequestParam(required = false) String keyword,
@Valid @ModelAttribute PageParams pageParams
) {
PageResult<Notice> result = noticeService.search(NoticeCategory.from(category), keyword, pageParams.toOffset());
return ApiResponse.success(PageResponse.from(result, AdminNoticeListItemResponse::from));
}

@Override
@PostMapping
public ApiResponse<NoticeCreateResponse> createNotice(
Expand Down Expand Up @@ -56,15 +74,4 @@ public ApiResponse<Void> deleteNotice(AdminApiUser apiUser, @PathVariable Long n
noticeService.delete(noticeId);
return ApiResponse.success();
}

@Override
@PatchMapping("/{noticeId}/pin")
public ApiResponse<Void> changePinned(
AdminApiUser apiUser,
@PathVariable Long noticeId,
@Valid @RequestBody NoticePinRequest request
) {
noticeService.changePinned(noticeId, request.pinned());
return ApiResponse.success();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ public record NoticeCreateRequest(
String title,

// content는 TEXT 컬럼(최대 65,535바이트)이다. 글자당 최대 4바이트로 잡아도 넘치지 않는 길이로 막는다
@Schema(description = "본문(HTML)", example = "<p>공지 내용입니다.</p>")
@Schema(description = "본문(일반 텍스트). 서식 없이 입력한 그대로 저장하며 줄바꿈도 보존한다", example = "공지 내용입니다.")
@NotBlank(message = "본문을 입력해 주세요.")
@Size(max = 16000, message = "본문은 16000자 이하로 입력해 주세요.")
String content,
Expand All @@ -33,7 +33,11 @@ public record NoticeCreateRequest(

@Schema(description = "첨부 파일 ID 목록", example = "[201]")
@NotNull(message = "첨부 파일 목록을 입력해 주세요.")
List<@NotNull(message = "첨부 파일 ID는 비워 둘 수 없습니다.") Long> attachmentFileIds
List<@NotNull(message = "첨부 파일 ID는 비워 둘 수 없습니다.") Long> attachmentFileIds,

@Schema(description = "상단 고정 여부", example = "false")
@NotNull(message = "고정 여부를 입력해 주세요.")
Boolean pinned
) {

public NoticeCreateCommand toCommand() {
Expand All @@ -42,7 +46,8 @@ public NoticeCreateCommand toCommand() {
content,
category,
imageFileIds.stream().distinct().toList(),
attachmentFileIds.stream().distinct().toList()
attachmentFileIds.stream().distinct().toList(),
pinned
);
}
}

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,7 @@
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import java.time.Instant;
import java.time.LocalDateTime;
import java.util.List;
import kr.ac.kookmin.stream.common.DateUtil;
import kr.ac.kookmin.stream.welfare.domain.notice.domain.NoticeCategory;
import kr.ac.kookmin.stream.welfare.domain.notice.domain.NoticeUpdateCommand;

Expand All @@ -18,7 +15,7 @@ public record NoticeUpdateRequest(
String title,

// content는 TEXT 컬럼(최대 65,535바이트)이다. 글자당 최대 4바이트로 잡아도 넘치지 않는 길이로 막는다
@Schema(description = "본문(HTML)", example = "<p>공지 내용입니다.</p>")
@Schema(description = "본문(일반 텍스트). 서식 없이 입력한 그대로 저장하며 줄바꿈도 보존한다", example = "공지 내용입니다.")
@NotBlank(message = "본문을 입력해 주세요.")
@Size(max = 16000, message = "본문은 16000자 이하로 입력해 주세요.")
String content,
Expand All @@ -35,10 +32,9 @@ public record NoticeUpdateRequest(
@NotNull(message = "첨부 파일 목록을 입력해 주세요.")
List<@NotNull(message = "첨부 파일 ID는 비워 둘 수 없습니다.") Long> attachmentFileIds,

@Schema(description = "수정 화면이 공지를 읽었을 때의 수정 시각(KST 기준 epoch 밀리초). 그 사이 다른 관리자가 고쳤다면 409",
example = "1756339200000")
@NotNull(message = "수정 시각을 입력해 주세요.")
Long updatedAt
@Schema(description = "상단 고정 여부", example = "false")
@NotNull(message = "고정 여부를 입력해 주세요.")
Boolean pinned
) {

public NoticeUpdateCommand toCommand() {
Expand All @@ -48,7 +44,7 @@ public NoticeUpdateCommand toCommand() {
category,
imageFileIds.stream().distinct().toList(),
attachmentFileIds.stream().distinct().toList(),
LocalDateTime.ofInstant(Instant.ofEpochMilli(updatedAt), DateUtil.KST)
pinned
);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
package kr.ac.kookmin.stream.api.admin.welfare.notice.response;

import java.time.LocalDateTime;
import kr.ac.kookmin.stream.welfare.domain.notice.domain.Notice;
import kr.ac.kookmin.stream.welfare.domain.notice.domain.NoticeCategory;

public record AdminNoticeListItemResponse(
Long noticeId,
String title,
NoticeCategory category,
boolean pinned,
LocalDateTime createdAt
) {

public static AdminNoticeListItemResponse from(Notice notice) {
return new AdminNoticeListItemResponse(
notice.getId(),
notice.getTitle(),
notice.getCategory(),
notice.isPinned(),
notice.getCreatedAt()
);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
* 스웨거 문서용 어노테이션만 이쪽에 두고 컨트롤러에는 라우팅과 본문만 남긴다. 경로 매핑과
* 파라미터 바인딩(@{@code RequestParam}, @{@code PathVariable} 등)은 구현체에 둔다.
*/
@Tag(name = "공지", description = "학생 앱 공지사항 조회")
@Tag(name = "공지", description = "공지사항 조회 (학생 앱·운영진 공용)")
public interface AppNoticeApi {

/** 공지 목록. 커서 기반 페이지네이션이며 카테고리로 필터링한다. */
Expand All @@ -30,8 +30,10 @@ ApiResponse<CursorSliceResponse<NoticeListItemResponse>> getNotices(
int size
);

/** 공지 상세. */
@Operation(summary = "공지 상세 조회")
/** 공지 상세. 운영진 수정 화면도 이 응답으로 폼을 채운다. */
@Operation(summary = "공지 상세 조회",
description = "공지 한 건을 조회한다. 고정 여부(pinned), 이미지·첨부 파일의 ID와 URL을 함께 내려준다. "
+ "삭제된 파일은 목록에서 빠진다.")
@ApiErrorCode(type = NoticeErrorCode.class, codes = {"NOTICE_NOT_FOUND"})
ApiResponse<NoticeDetailResponse> getNotice(Long noticeId);
}
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ public record NoticeDetailResponse(
String title,
String content,
NoticeCategory category,
boolean pinned,
List<Image> images,
List<Attachment> attachments,
LocalDateTime createdAt
Expand Down Expand Up @@ -44,6 +45,7 @@ public static NoticeDetailResponse from(
notice.getTitle(),
notice.getContent(),
notice.getCategory(),
notice.isPinned(),
images,
attachments,
notice.getCreatedAt()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,14 +38,14 @@ public static Notice of(
return new Notice(id, title, content, category, pinned, createdBy, attachmentIds, imageIds, createdAt, updatedAt);
}

// 새 공지는 고정되지 않은 채로 등록한다. 생성·수정 시각은 DB가 채운다
// 생성·수정 시각은 DB가 채운다
public static Notice create(Long createdBy, NoticeCreateCommand command) {
return new Notice(
null,
command.title(),
command.content(),
command.category(),
false,
command.pinned(),
createdBy,
command.attachmentIds(),
command.imageIds(),
Expand All @@ -60,9 +60,6 @@ public void update(NoticeUpdateCommand command) {
this.category = command.category();
this.attachmentIds = command.attachmentIds();
this.imageIds = command.imageIds();
}

public void changePinned(boolean pinned) {
this.pinned = pinned;
this.pinned = command.pinned();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ public record NoticeCreateCommand(
String content,
NoticeCategory category,
List<Long> imageIds,
List<Long> attachmentIds
List<Long> attachmentIds,
boolean pinned
) {
}
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,7 @@
public enum NoticeErrorCode implements ErrorCode {

NOTICE_NOT_FOUND(ErrorStatus.NOT_FOUND, "공지를 찾을 수 없습니다."),
NOTICE_INVALID_CURSOR(ErrorStatus.BAD_REQUEST, "유효하지 않은 커서입니다."),
NOTICE_UPDATE_CONFLICT(ErrorStatus.CONFLICT, "다른 관리자가 먼저 공지를 수정했습니다. 새로고침한 뒤 다시 수정해 주세요.");
NOTICE_INVALID_CURSOR(ErrorStatus.BAD_REQUEST, "유효하지 않은 커서입니다.");

private final int status;
private final String message;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,17 +1,13 @@
package kr.ac.kookmin.stream.welfare.domain.notice.domain;

import java.time.LocalDateTime;
import java.util.List;

/**
* @param expectedUpdatedAt 수정 화면이 공지를 읽었을 때의 수정 시각. 그 사이 다른 관리자가 고쳤는지 가려내는 데 쓴다
*/
public record NoticeUpdateCommand(
String title,
String content,
NoticeCategory category,
List<Long> imageIds,
List<Long> attachmentIds,
LocalDateTime expectedUpdatedAt
boolean pinned
) {
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,22 @@

import java.util.Optional;
import kr.ac.kookmin.stream.common.CursorSliceResult;
import kr.ac.kookmin.stream.common.PageOffset;
import kr.ac.kookmin.stream.common.PageResult;
import kr.ac.kookmin.stream.welfare.domain.notice.domain.Notice;
import kr.ac.kookmin.stream.welfare.domain.notice.domain.NoticeCategory;
import kr.ac.kookmin.stream.welfare.domain.notice.domain.NoticeCursor;

public interface NoticeRepository {
CursorSliceResult<Notice> findAll(NoticeCategory category, NoticeCursor cursor, int size);

/**
* 삭제되지 않은 공지를 고정 우선, 최신순으로 페이지 단위로 조회한다. {@code category}가 null이 아니면 그 종류만,
* 제목에 {@code keyword}가 들어 있는 공지만 고르고, 각각 null이면 그 조건 없이 조회한다.
* 검색어는 호출하는 쪽이 공백을 정리해서 넘기고(공백뿐이면 null), 검색어의 {@code %}·{@code _}는 글자 그대로 검색한다.
*/
PageResult<Notice> search(NoticeCategory category, String keyword, PageOffset pageOffset);

Optional<Notice> findById(Long id);
Notice save(Notice notice);

Expand Down
Loading