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:file"))
implementation(project(":core:domain:internal"))
implementation(project(":core:domain:member"))
implementation(project(":core:domain:welfare"))
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
package kr.ac.kookmin.stream.api.admin.welfare.notice;

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.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.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.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;

/**
* 운영진 공지 API의 문서 명세. 구현은 {@link AdminNoticeController}가 맡는다.
* <p>
* 접근은 {@code ADMIN} role이면 된다. 부서 제한은 두지 않았다.
*/
@Tag(name = "공지 관리", description = "운영진 공지 작성·수정·삭제·고정")
public interface AdminNoticeApi {

/** 공지 작성. 작성자는 토큰의 관리자로 정해진다. */
@Operation(summary = "공지 작성",
description = "공지를 등록한다. 이미지·첨부 파일은 업로드를 마친 파일 ID로 넘기며, 존재하지 않는 파일이 하나라도 있으면 "
+ "등록하지 않는다. 새 공지는 고정되지 않은 상태로 만들어진다.")
@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로 거부한다.")
@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"})
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
@@ -0,0 +1,70 @@
package kr.ac.kookmin.stream.api.admin.welfare.notice;

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.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.welfare.domain.notice.domain.Notice;
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.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;

@RestController
@RequestMapping("/v1/admin/notices")
@RequiredArgsConstructor
public class AdminNoticeController implements AdminNoticeApi {

private final NoticeService noticeService;
private final AdminNoticeWriteUseCase adminNoticeWriteUseCase;

@Override
@PostMapping
public ApiResponse<NoticeCreateResponse> createNotice(
AdminApiUser apiUser,
@Valid @RequestBody NoticeCreateRequest request
) {
Notice notice = adminNoticeWriteUseCase.create(apiUser.userId(), request.toCommand());
return ApiResponse.success(NoticeCreateResponse.from(notice));
}

@Override
@PutMapping("/{noticeId}")
public ApiResponse<NoticeUpdateResponse> updateNotice(
AdminApiUser apiUser,
@PathVariable Long noticeId,
@Valid @RequestBody NoticeUpdateRequest request
) {
Notice notice = adminNoticeWriteUseCase.update(noticeId, request.toCommand());
return ApiResponse.success(NoticeUpdateResponse.from(notice));
}

@Override
@DeleteMapping("/{noticeId}")
public ApiResponse<Void> deleteNotice(AdminApiUser apiUser, @PathVariable Long noticeId) {
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
@@ -0,0 +1,48 @@
package kr.ac.kookmin.stream.api.admin.welfare.notice.request;

import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import java.util.List;
import kr.ac.kookmin.stream.welfare.domain.notice.domain.NoticeCategory;
import kr.ac.kookmin.stream.welfare.domain.notice.domain.NoticeCreateCommand;

/**
* 작성자는 요청 본문이 아니라 토큰의 관리자 ID로 정한다. 본문에 {@code createdBy}가 와도 읽지 않는다.
*/
public record NoticeCreateRequest(
@Schema(description = "제목", example = "제목 예시")
@NotBlank(message = "제목을 입력해 주세요.")
@Size(max = 200, message = "제목은 200자 이하로 입력해 주세요.")
String title,

// content는 TEXT 컬럼(최대 65,535바이트)이다. 글자당 최대 4바이트로 잡아도 넘치지 않는 길이로 막는다
@Schema(description = "본문(HTML)", example = "<p>공지 내용입니다.</p>")
@NotBlank(message = "본문을 입력해 주세요.")
@Size(max = 16000, message = "본문은 16000자 이하로 입력해 주세요.")
String content,

@Schema(description = "공지 종류")
@NotNull(message = "공지 종류를 입력해 주세요.")
NoticeCategory category,

@Schema(description = "이미지 파일 ID 목록. 첫 번째가 목록의 썸네일이 된다", example = "[101, 102]")
@NotNull(message = "이미지 파일 목록을 입력해 주세요.")
List<@NotNull(message = "이미지 파일 ID는 비워 둘 수 없습니다.") Long> imageFileIds,

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

public NoticeCreateCommand toCommand() {
return new NoticeCreateCommand(
title,
content,
category,
imageFileIds.stream().distinct().toList(),
attachmentFileIds.stream().distinct().toList()
);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package kr.ac.kookmin.stream.api.admin.welfare.notice.request;

import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotNull;

public record NoticePinRequest(
@Schema(description = "고정 여부. true면 목록 맨 위에 고정하고 false면 해제한다", example = "true")
@NotNull(message = "고정 여부를 입력해 주세요.")
Boolean pinned
) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
package kr.ac.kookmin.stream.api.admin.welfare.notice.request;

import io.swagger.v3.oas.annotations.media.Schema;
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;

public record NoticeUpdateRequest(
@Schema(description = "제목", example = "제목 예시")
@NotBlank(message = "제목을 입력해 주세요.")
@Size(max = 200, message = "제목은 200자 이하로 입력해 주세요.")
String title,

// content는 TEXT 컬럼(최대 65,535바이트)이다. 글자당 최대 4바이트로 잡아도 넘치지 않는 길이로 막는다
@Schema(description = "본문(HTML)", example = "<p>공지 내용입니다.</p>")
@NotBlank(message = "본문을 입력해 주세요.")
@Size(max = 16000, message = "본문은 16000자 이하로 입력해 주세요.")
String content,

@Schema(description = "공지 종류")
@NotNull(message = "공지 종류를 입력해 주세요.")
NoticeCategory category,

@Schema(description = "이미지 파일 ID 목록. 첫 번째가 목록의 썸네일이 된다", example = "[101, 103]")
@NotNull(message = "이미지 파일 목록을 입력해 주세요.")
List<@NotNull(message = "이미지 파일 ID는 비워 둘 수 없습니다.") Long> imageFileIds,

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

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

public NoticeUpdateCommand toCommand() {
return new NoticeUpdateCommand(
title,
content,
category,
imageFileIds.stream().distinct().toList(),
attachmentFileIds.stream().distinct().toList(),
LocalDateTime.ofInstant(Instant.ofEpochMilli(updatedAt), DateUtil.KST)
);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package kr.ac.kookmin.stream.api.admin.welfare.notice.response;

import kr.ac.kookmin.stream.welfare.domain.notice.domain.Notice;

public record NoticeCreateResponse(Long noticeId) {

public static NoticeCreateResponse from(Notice notice) {
return new NoticeCreateResponse(notice.getId());
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package kr.ac.kookmin.stream.api.admin.welfare.notice.response;

import kr.ac.kookmin.stream.welfare.domain.notice.domain.Notice;

public record NoticeUpdateResponse(Long noticeId) {

public static NoticeUpdateResponse from(Notice notice) {
return new NoticeUpdateResponse(notice.getId());
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
package kr.ac.kookmin.stream.api.admin.welfare.notice.usecase;

import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import kr.ac.kookmin.stream.common.BusinessException;
import kr.ac.kookmin.stream.file.domain.FileErrorCode;
import kr.ac.kookmin.stream.file.service.FileService;
import kr.ac.kookmin.stream.welfare.domain.notice.domain.Notice;
import kr.ac.kookmin.stream.welfare.domain.notice.domain.NoticeCreateCommand;
import kr.ac.kookmin.stream.welfare.domain.notice.domain.NoticeUpdateCommand;
import kr.ac.kookmin.stream.welfare.domain.notice.service.NoticeService;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;

/**
* 공지를 쓰기 전에 연결할 이미지·첨부 파일이 실제로 있는지 확인한다. 공지(welfare)와 파일(file)은 서로 다른 도메인이라
* 도메인끼리 직접 의존하지 않고 이 UseCase가 두 Service를 조합한다.
* <p>
* 파일 확인과 공지 저장은 한 트랜잭션으로 묶지 않는다. 확인 직후 파일이 지워져도 공지 조회가 삭제된 파일을
* 목록에서 조용히 제외하므로 원자성이 필요하지 않다.
*/
@Component
@RequiredArgsConstructor
public class AdminNoticeWriteUseCase {

private final NoticeService noticeService;
private final FileService fileService;

public Notice create(Long createdBy, NoticeCreateCommand command) {
requireFilesExist(command.imageIds(), command.attachmentIds());
return noticeService.create(createdBy, command);
}

public Notice update(Long noticeId, NoticeUpdateCommand command) {
requireFilesExist(command.imageIds(), command.attachmentIds());
return noticeService.update(noticeId, command);
}

private void requireFilesExist(List<Long> imageIds, List<Long> attachmentIds) {
Set<Long> fileIds = Stream.concat(imageIds.stream(), attachmentIds.stream())
.collect(Collectors.toUnmodifiableSet());

if (fileService.findAllByIdIn(List.copyOf(fileIds)).size() != fileIds.size()) {
throw new BusinessException(FileErrorCode.FILE_NOT_FOUND);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ public class Notice {
private List<Long> attachmentIds;
private List<Long> imageIds;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;

public static Notice of(
Long id,
Expand All @@ -31,8 +32,37 @@ public static Notice of(
Long createdBy,
List<Long> attachmentIds,
List<Long> imageIds,
LocalDateTime createdAt
LocalDateTime createdAt,
LocalDateTime updatedAt
) {
return new Notice(id, title, content, category, pinned, createdBy, attachmentIds, imageIds, createdAt);
return new Notice(id, title, content, category, pinned, createdBy, attachmentIds, imageIds, createdAt, updatedAt);
}

// 새 공지는 고정되지 않은 채로 등록한다. 생성·수정 시각은 DB가 채운다
public static Notice create(Long createdBy, NoticeCreateCommand command) {
return new Notice(
null,
command.title(),
command.content(),
command.category(),
false,
createdBy,
command.attachmentIds(),
command.imageIds(),
null,
null
);
}

public void update(NoticeUpdateCommand command) {
this.title = command.title();
this.content = command.content();
this.category = command.category();
this.attachmentIds = command.attachmentIds();
this.imageIds = command.imageIds();
}

public void changePinned(boolean pinned) {
this.pinned = pinned;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package kr.ac.kookmin.stream.welfare.domain.notice.domain;

import java.util.List;

public record NoticeCreateCommand(
String title,
String content,
NoticeCategory category,
List<Long> imageIds,
List<Long> attachmentIds
) {
}
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,8 @@
public enum NoticeErrorCode implements ErrorCode {

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

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