diff --git a/src/main/java/com/campus/campus/domain/councilnotice/application/dto/request/NoticeRequest.java b/src/main/java/com/campus/campus/domain/councilnotice/application/dto/request/NoticeRequest.java new file mode 100644 index 0000000..2962a76 --- /dev/null +++ b/src/main/java/com/campus/campus/domain/councilnotice/application/dto/request/NoticeRequest.java @@ -0,0 +1,10 @@ +package com.campus.campus.domain.councilnotice.application.dto.request; + +import java.util.List; + +public record NoticeRequest( + String title, + String content, + List imageUrls +) { +} diff --git a/src/main/java/com/campus/campus/domain/councilnotice/application/dto/response/NoticeListItemResponse.java b/src/main/java/com/campus/campus/domain/councilnotice/application/dto/response/NoticeListItemResponse.java new file mode 100644 index 0000000..a18f435 --- /dev/null +++ b/src/main/java/com/campus/campus/domain/councilnotice/application/dto/response/NoticeListItemResponse.java @@ -0,0 +1,15 @@ +package com.campus.campus.domain.councilnotice.application.dto.response; + +import java.time.LocalDateTime; + +import lombok.Builder; + +@Builder +public record NoticeListItemResponse( + Long id, + String title, + boolean isWriter, + LocalDateTime createdAt, + LocalDateTime updatedAt +) { +} diff --git a/src/main/java/com/campus/campus/domain/councilnotice/application/dto/response/NoticeResponse.java b/src/main/java/com/campus/campus/domain/councilnotice/application/dto/response/NoticeResponse.java new file mode 100644 index 0000000..4a83515 --- /dev/null +++ b/src/main/java/com/campus/campus/domain/councilnotice/application/dto/response/NoticeResponse.java @@ -0,0 +1,20 @@ +package com.campus.campus.domain.councilnotice.application.dto.response; + +import java.time.LocalDateTime; +import java.util.List; + +import lombok.Builder; + +@Builder +public record NoticeResponse( + Long id, + Long writerId, + String writerName, + boolean isWriter, + String title, + String content, + List images, + LocalDateTime createdAt, + LocalDateTime updatedAt +) { +} diff --git a/src/main/java/com/campus/campus/domain/councilnotice/application/exception/ErrorCode.java b/src/main/java/com/campus/campus/domain/councilnotice/application/exception/ErrorCode.java new file mode 100644 index 0000000..54f3aea --- /dev/null +++ b/src/main/java/com/campus/campus/domain/councilnotice/application/exception/ErrorCode.java @@ -0,0 +1,23 @@ +package com.campus.campus.domain.councilnotice.application.exception; + +import org.springframework.http.HttpStatus; + +import com.campus.campus.global.common.exception.ErrorCodeInterface; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public enum ErrorCode implements ErrorCodeInterface { + + NOTICE_NOT_FOUND(2501, HttpStatus.NOT_FOUND, "공지를 찾을 수 없습니다."), + NOT_NOTICE_WRITER(2502, HttpStatus.FORBIDDEN, "작성자만 해당 공지를 수정하거나 삭제할 수 있습니다."), + NOTICE_IMAGE_LIMIT_EXCEEDED(2503, HttpStatus.BAD_REQUEST, "공지 이미지는 최대 10개까지 등록할 수 있습니다."), + //OCI 공지 이미지 + NOTICE_OCI_IMAGE_DELETE_FAILED(2504, HttpStatus.INTERNAL_SERVER_ERROR, "OCI 이미지 삭제에 실패했습니다."); + + private final int code; + private final HttpStatus status; + private final String message; +} diff --git a/src/main/java/com/campus/campus/domain/councilnotice/application/exception/NotNoticeWriterException.java b/src/main/java/com/campus/campus/domain/councilnotice/application/exception/NotNoticeWriterException.java new file mode 100644 index 0000000..053a3af --- /dev/null +++ b/src/main/java/com/campus/campus/domain/councilnotice/application/exception/NotNoticeWriterException.java @@ -0,0 +1,9 @@ +package com.campus.campus.domain.councilnotice.application.exception; + +import com.campus.campus.global.common.exception.ApplicationException; + +public class NotNoticeWriterException extends ApplicationException { + public NotNoticeWriterException() { + super(ErrorCode.NOT_NOTICE_WRITER); + } +} diff --git a/src/main/java/com/campus/campus/domain/councilnotice/application/exception/NoticeImageLimitExceededException.java b/src/main/java/com/campus/campus/domain/councilnotice/application/exception/NoticeImageLimitExceededException.java new file mode 100644 index 0000000..6380b77 --- /dev/null +++ b/src/main/java/com/campus/campus/domain/councilnotice/application/exception/NoticeImageLimitExceededException.java @@ -0,0 +1,9 @@ +package com.campus.campus.domain.councilnotice.application.exception; + +import com.campus.campus.global.common.exception.ApplicationException; + +public class NoticeImageLimitExceededException extends ApplicationException { + public NoticeImageLimitExceededException() { + super(ErrorCode.NOTICE_IMAGE_LIMIT_EXCEEDED); + } +} diff --git a/src/main/java/com/campus/campus/domain/councilnotice/application/exception/NoticeNotFoundException.java b/src/main/java/com/campus/campus/domain/councilnotice/application/exception/NoticeNotFoundException.java new file mode 100644 index 0000000..9c31b61 --- /dev/null +++ b/src/main/java/com/campus/campus/domain/councilnotice/application/exception/NoticeNotFoundException.java @@ -0,0 +1,9 @@ +package com.campus.campus.domain.councilnotice.application.exception; + +import com.campus.campus.global.common.exception.ApplicationException; + +public class NoticeNotFoundException extends ApplicationException { + public NoticeNotFoundException() { + super(ErrorCode.NOTICE_NOT_FOUND); + } +} diff --git a/src/main/java/com/campus/campus/domain/councilnotice/application/exception/NoticeOciImageDeleteFailedException.java b/src/main/java/com/campus/campus/domain/councilnotice/application/exception/NoticeOciImageDeleteFailedException.java new file mode 100644 index 0000000..f818b9c --- /dev/null +++ b/src/main/java/com/campus/campus/domain/councilnotice/application/exception/NoticeOciImageDeleteFailedException.java @@ -0,0 +1,9 @@ +package com.campus.campus.domain.councilnotice.application.exception; + +import com.campus.campus.global.common.exception.ApplicationException; + +public class NoticeOciImageDeleteFailedException extends ApplicationException { + public NoticeOciImageDeleteFailedException() { + super(ErrorCode.NOTICE_OCI_IMAGE_DELETE_FAILED); + } +} diff --git a/src/main/java/com/campus/campus/domain/councilnotice/application/mapper/StudentCouncilNoticeMapper.java b/src/main/java/com/campus/campus/domain/councilnotice/application/mapper/StudentCouncilNoticeMapper.java new file mode 100644 index 0000000..9925279 --- /dev/null +++ b/src/main/java/com/campus/campus/domain/councilnotice/application/mapper/StudentCouncilNoticeMapper.java @@ -0,0 +1,58 @@ +package com.campus.campus.domain.councilnotice.application.mapper; + +import java.util.List; + +import org.springframework.stereotype.Component; + +import com.campus.campus.domain.council.domain.entity.StudentCouncil; +import com.campus.campus.domain.councilnotice.application.dto.request.NoticeRequest; +import com.campus.campus.domain.councilnotice.application.dto.response.NoticeListItemResponse; +import com.campus.campus.domain.councilnotice.application.dto.response.NoticeResponse; +import com.campus.campus.domain.councilnotice.domain.entity.NoticeImage; +import com.campus.campus.domain.councilnotice.domain.entity.StudentCouncilNotice; + +import lombok.RequiredArgsConstructor; + +@Component +@RequiredArgsConstructor +public class StudentCouncilNoticeMapper { + + public StudentCouncilNotice createStudentCouncilNotice(StudentCouncil writer, NoticeRequest dto) { + return StudentCouncilNotice.builder() + .title(dto.title()) + .content(dto.content()) + .writer(writer) + .build(); + } + + public NoticeImage createStudentCouncilNoticeImage(StudentCouncilNotice notice, String imageUrl) { + return NoticeImage.builder() + .notice(notice) + .imageUrl(imageUrl) + .build(); + } + + public NoticeResponse toNoticeResponse(StudentCouncilNotice notice, List imageUrls, Long councilId) { + return NoticeResponse.builder() + .id(notice.getId()) + .writerId(notice.getWriter().getId()) + .writerName(notice.getWriter().getFullCouncilName()) + .isWriter(notice.isWrittenByCouncil(councilId)) + .title(notice.getTitle()) + .content(notice.getContent()) + .images(imageUrls) + .createdAt(notice.getCreatedAt()) + .updatedAt(notice.getUpdatedAt()) + .build(); + } + + public NoticeListItemResponse toNoticeListItemResponse(StudentCouncilNotice notice, Long currentUserId) { + return NoticeListItemResponse.builder() + .id(notice.getId()) + .title(notice.getTitle()) + .isWriter(notice.isWrittenByCouncil(currentUserId)) + .createdAt(notice.getCreatedAt()) + .updatedAt(notice.getUpdatedAt()) + .build(); + } +} diff --git a/src/main/java/com/campus/campus/domain/councilnotice/application/service/StudentCouncilNoticeService.java b/src/main/java/com/campus/campus/domain/councilnotice/application/service/StudentCouncilNoticeService.java new file mode 100644 index 0000000..a145707 --- /dev/null +++ b/src/main/java/com/campus/campus/domain/councilnotice/application/service/StudentCouncilNoticeService.java @@ -0,0 +1,199 @@ +package com.campus.campus.domain.councilnotice.application.service; + +import java.util.List; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.campus.campus.domain.council.application.exception.StudentCouncilNotFoundException; +import com.campus.campus.domain.council.domain.entity.StudentCouncil; +import com.campus.campus.domain.council.domain.repository.StudentCouncilRepository; +import com.campus.campus.domain.councilnotice.application.dto.request.NoticeRequest; +import com.campus.campus.domain.councilnotice.application.dto.response.NoticeListItemResponse; +import com.campus.campus.domain.councilnotice.application.dto.response.NoticeResponse; +import com.campus.campus.domain.councilnotice.application.exception.NotNoticeWriterException; +import com.campus.campus.domain.councilnotice.application.exception.NoticeImageLimitExceededException; +import com.campus.campus.domain.councilnotice.application.exception.NoticeNotFoundException; +import com.campus.campus.domain.councilnotice.application.exception.NoticeOciImageDeleteFailedException; +import com.campus.campus.domain.councilnotice.application.mapper.StudentCouncilNoticeMapper; +import com.campus.campus.domain.councilnotice.domain.entity.NoticeImage; +import com.campus.campus.domain.councilnotice.domain.entity.StudentCouncilNotice; +import com.campus.campus.domain.councilnotice.domain.repository.NoticeImageRepository; +import com.campus.campus.domain.councilnotice.domain.repository.StudentCouncilNoticeRepository; +import com.campus.campus.global.oci.application.service.PresignedUrlService; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Service +@RequiredArgsConstructor +public class StudentCouncilNoticeService { + + private static final int MAX_IMAGE_COUNT = 10; + + private final StudentCouncilNoticeRepository noticeRepository; + private final StudentCouncilRepository studentCouncilRepository; + private final NoticeImageRepository noticeImageRepository; + private final PresignedUrlService presignedUrlService; + private final StudentCouncilNoticeMapper studentCouncilNoticeMapper; + + @Transactional + public NoticeResponse create(Long councilId, NoticeRequest dto) { + + if (dto.imageUrls() != null && dto.imageUrls().size() > MAX_IMAGE_COUNT) { + throw new NoticeImageLimitExceededException(); + } + + StudentCouncil writer = studentCouncilRepository.findByIdWithDetailsAndDeletedAtIsNull(councilId) + .orElseThrow(StudentCouncilNotFoundException::new); + + StudentCouncilNotice notice = + noticeRepository.save(studentCouncilNoticeMapper.createStudentCouncilNotice(writer, dto)); + + if (dto.imageUrls() != null && !dto.imageUrls().isEmpty()) { + List images = dto.imageUrls().stream() + .map(imageUrl -> studentCouncilNoticeMapper.createStudentCouncilNoticeImage(notice, imageUrl)) + .toList(); + + noticeImageRepository.saveAll(images); + } + + List imageUrls = noticeImageRepository + .findAllByNoticeOrderByIdAsc(notice) + .stream() + .map(NoticeImage::getImageUrl) + .toList(); + + return studentCouncilNoticeMapper.toNoticeResponse(notice, imageUrls, councilId); + } + + @Transactional(readOnly = true) + public NoticeResponse findById(Long noticeId, Long councilId) { + + StudentCouncilNotice notice = noticeRepository.findByIdWithFullInfo(noticeId) + .orElseThrow(NoticeNotFoundException::new); + + List imageUrls = noticeImageRepository + .findAllByNoticeOrderByIdAsc(notice) + .stream() + .map(NoticeImage::getImageUrl) + .toList(); + + return studentCouncilNoticeMapper.toNoticeResponse(notice, imageUrls, councilId); + } + + @Transactional(readOnly = true) + public Page findAll(int page, int size, Long councilId) { + + Pageable pageable = PageRequest.of( + Math.max(page - 1, 0), + size, + Sort.by(Sort.Direction.DESC, "createdAt") + ); + + Page notices = noticeRepository.findAllWithWriter(pageable); + + return notices.map(notice -> + studentCouncilNoticeMapper.toNoticeListItemResponse(notice, councilId) + ); + } + + @Transactional + public NoticeResponse update(Long councilId, Long noticeId, NoticeRequest dto) { + + if (dto.imageUrls() != null && dto.imageUrls().size() > MAX_IMAGE_COUNT) { + throw new NoticeImageLimitExceededException(); + } + + StudentCouncilNotice notice = noticeRepository.findByIdWithFullInfo(noticeId) + .orElseThrow(NoticeNotFoundException::new); + + if (!notice.isWrittenByCouncil(councilId)) { + throw new NotNoticeWriterException(); + } + + // 업데이트 전 기존 이미지 스냅샷 + List oldImages = noticeImageRepository.findAllByNotice(notice); + + notice.update(dto.title(), dto.content()); + + // DB 이미지 교체 + noticeImageRepository.deleteByNotice(notice); + + if (dto.imageUrls() != null) { + for (String imageUrl : dto.imageUrls()) { + noticeImageRepository.save( + studentCouncilNoticeMapper.createStudentCouncilNoticeImage(notice, imageUrl)); + } + } + + cleanupUnusedImages(oldImages, dto); + + List imageUrls = noticeImageRepository + .findAllByNoticeOrderByIdAsc(notice) + .stream() + .map(NoticeImage::getImageUrl) + .toList(); + + return studentCouncilNoticeMapper.toNoticeResponse(notice, imageUrls, councilId); + } + + @Transactional + public void delete(Long councilId, Long noticeId) { + + StudentCouncilNotice notice = noticeRepository.findByIdWithFullInfo(noticeId) + .orElseThrow(NoticeNotFoundException::new); + + if (!notice.isWrittenByCouncil(councilId)) { + throw new NotNoticeWriterException(); + } + + List images = noticeImageRepository.findAllByNotice(notice); + + List deleteTargets = images.stream() + .map(NoticeImage::getImageUrl) + .toList(); + + noticeImageRepository.deleteAll(images); + noticeRepository.delete(notice); + + for (String imageUrl : deleteTargets) { + if (imageUrl == null || imageUrl.isBlank()) { + continue; + } + + try { + presignedUrlService.deleteImage(imageUrl); + } catch (NoticeOciImageDeleteFailedException e) { + log.warn("OCI 파일 삭제 실패 (파일이 없을 수 있음): {}", imageUrl, e); + } + } + } + + private void cleanupUnusedImages(List oldImages, NoticeRequest dto) { + List newUrls = dto.imageUrls() == null ? List.of() : dto.imageUrls(); + + List deleteTargets = oldImages.stream() + .map(NoticeImage::getImageUrl) + .filter(url -> !newUrls.contains(url)) + .toList(); + + //삭제 + for (String imageUrl : deleteTargets) { + if (imageUrl == null || imageUrl.isBlank()) { + continue; + } + + try { + presignedUrlService.deleteImage(imageUrl); + } catch (NoticeOciImageDeleteFailedException e) { + log.warn("OCI 파일 삭제 실패 (파일이 없을 수 있음): {}", imageUrl, e); + } + } + } +} diff --git a/src/main/java/com/campus/campus/domain/councilnotice/domain/entity/NoticeImage.java b/src/main/java/com/campus/campus/domain/councilnotice/domain/entity/NoticeImage.java new file mode 100644 index 0000000..4f8a3cf --- /dev/null +++ b/src/main/java/com/campus/campus/domain/councilnotice/domain/entity/NoticeImage.java @@ -0,0 +1,36 @@ +package com.campus.campus.domain.councilnotice.domain.entity; + +import com.campus.campus.global.entity.BaseEntity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PRIVATE) +@Builder +public class NoticeImage extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "notice_id") + private StudentCouncilNotice notice; + + @Column(nullable = false) + private String imageUrl; +} diff --git a/src/main/java/com/campus/campus/domain/councilnotice/domain/entity/StudentCouncilNotice.java b/src/main/java/com/campus/campus/domain/councilnotice/domain/entity/StudentCouncilNotice.java new file mode 100644 index 0000000..9daf1a1 --- /dev/null +++ b/src/main/java/com/campus/campus/domain/councilnotice/domain/entity/StudentCouncilNotice.java @@ -0,0 +1,54 @@ +package com.campus.campus.domain.councilnotice.domain.entity; + +import com.campus.campus.domain.council.domain.entity.StudentCouncil; +import com.campus.campus.global.entity.BaseEntity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.Lob; +import jakarta.persistence.ManyToOne; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PRIVATE) +@Builder +public class StudentCouncilNotice extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "writer_id") + private StudentCouncil writer; + + @Column(nullable = false, length = 100) + private String title; + + @Lob + @Column(nullable = false) + private String content; + + public void update(String title, String content) { + if (title == null || content == null) { + throw new IllegalArgumentException("제목과 내용은 필수입니다."); + } + this.title = title; + this.content = content; + } + + public boolean isWrittenByCouncil(Long councilId) { + return writer != null && writer.getId().equals(councilId); + } +} diff --git a/src/main/java/com/campus/campus/domain/councilnotice/domain/repository/NoticeImageRepository.java b/src/main/java/com/campus/campus/domain/councilnotice/domain/repository/NoticeImageRepository.java new file mode 100644 index 0000000..b305a08 --- /dev/null +++ b/src/main/java/com/campus/campus/domain/councilnotice/domain/repository/NoticeImageRepository.java @@ -0,0 +1,17 @@ +package com.campus.campus.domain.councilnotice.domain.repository; + +import java.util.List; + +import org.springframework.data.jpa.repository.JpaRepository; + +import com.campus.campus.domain.councilnotice.domain.entity.NoticeImage; +import com.campus.campus.domain.councilnotice.domain.entity.StudentCouncilNotice; + +public interface NoticeImageRepository extends JpaRepository { + + List findAllByNotice(StudentCouncilNotice notice); + + List findAllByNoticeOrderByIdAsc(StudentCouncilNotice notice); + + void deleteByNotice(StudentCouncilNotice notice); +} diff --git a/src/main/java/com/campus/campus/domain/councilnotice/domain/repository/StudentCouncilNoticeRepository.java b/src/main/java/com/campus/campus/domain/councilnotice/domain/repository/StudentCouncilNoticeRepository.java new file mode 100644 index 0000000..cda4f6f --- /dev/null +++ b/src/main/java/com/campus/campus/domain/councilnotice/domain/repository/StudentCouncilNoticeRepository.java @@ -0,0 +1,27 @@ +package com.campus.campus.domain.councilnotice.domain.repository; + +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; + +import com.campus.campus.domain.councilnotice.domain.entity.StudentCouncilNotice; + +public interface StudentCouncilNoticeRepository extends JpaRepository { + + @Query("SELECT n FROM StudentCouncilNotice n " + + "JOIN FETCH n.writer w " + + "JOIN FETCH w.school " + + "LEFT JOIN FETCH w.college " + + "LEFT JOIN FETCH w.major " + + "WHERE n.id = :noticeId") + Optional findByIdWithFullInfo(@Param("noticeId") Long noticeId); + + @Query(value= "SELECT n FROM StudentCouncilNotice n " + + "JOIN FETCH n.writer " , + countQuery = "SELECT COUNT(n) FROM StudentCouncilNotice n") + Page findAllWithWriter(Pageable pageable); +} diff --git a/src/main/java/com/campus/campus/domain/councilnotice/presentation/NoticeResponseCode.java b/src/main/java/com/campus/campus/domain/councilnotice/presentation/NoticeResponseCode.java new file mode 100644 index 0000000..e0395de --- /dev/null +++ b/src/main/java/com/campus/campus/domain/councilnotice/presentation/NoticeResponseCode.java @@ -0,0 +1,24 @@ +package com.campus.campus.domain.councilnotice.presentation; + +import org.springframework.http.HttpStatus; + +import com.campus.campus.global.common.response.ResponseCodeInterface; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public enum NoticeResponseCode implements ResponseCodeInterface { + + NOTICE_CREATE_SUCCESS(201, HttpStatus.CREATED, "공지 생성에 성공했습니다."), + NOTICE_READ_SUCCESS(200, HttpStatus.OK, "공지 조회에 성공했습니다."), + NOTICE_LIST_READ_SUCCESS(200, HttpStatus.OK, "공지 목록 조회에 성공했습니다."), + NOTICE_UPDATE_SUCCESS(200, HttpStatus.OK, "공지 수정에 성공했습니다."), + NOTICE_DELETE_SUCCESS(200, HttpStatus.OK, "공지 삭제에 성공했습니다."); + + private final int code; + private final HttpStatus status; + private final String message; +} + diff --git a/src/main/java/com/campus/campus/domain/councilnotice/presentation/StudentCouncilNoticeController.java b/src/main/java/com/campus/campus/domain/councilnotice/presentation/StudentCouncilNoticeController.java new file mode 100644 index 0000000..15d8b11 --- /dev/null +++ b/src/main/java/com/campus/campus/domain/councilnotice/presentation/StudentCouncilNoticeController.java @@ -0,0 +1,94 @@ +package com.campus.campus.domain.councilnotice.presentation; + +import org.springframework.data.domain.Page; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +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; + +import com.campus.campus.domain.councilnotice.application.dto.request.NoticeRequest; +import com.campus.campus.domain.councilnotice.application.dto.response.NoticeListItemResponse; +import com.campus.campus.domain.councilnotice.application.dto.response.NoticeResponse; +import com.campus.campus.domain.councilnotice.application.service.StudentCouncilNoticeService; +import com.campus.campus.global.annotation.CurrentCouncilId; +import com.campus.campus.global.common.response.CommonResponse; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/student-council/notices") +@Tag(name = "Student Council Notice", description = "학생회(COUNCIL) 권한 전용 공지 게시글 관리 API") +public class StudentCouncilNoticeController { + + private final StudentCouncilNoticeService noticeService; + + @PostMapping + @PreAuthorize("hasRole('COUNCIL')") + @Operation(summary = "학생회 공지 작성") + public CommonResponse create( + @CurrentCouncilId Long councilId, + @RequestBody @Valid NoticeRequest dto + ) { + NoticeResponse response = noticeService.create(councilId, dto); + + return CommonResponse.success(NoticeResponseCode.NOTICE_CREATE_SUCCESS, response); + } + + @GetMapping + @Operation(summary = "학생회 공지 목록 조회") + public CommonResponse> getNotices( + @RequestParam(defaultValue = "1") int page, + @RequestParam(defaultValue = "10") int size, + @CurrentCouncilId(required = false) Long councilId + ) { + Page response = noticeService.findAll(page, size, councilId); + + return CommonResponse.success(NoticeResponseCode.NOTICE_LIST_READ_SUCCESS, response); + } + + @PutMapping("/{noticeId}") + @PreAuthorize("hasRole('COUNCIL')") + @Operation(summary = "학생회 공지 수정") + public CommonResponse update( + @PathVariable Long noticeId, + @CurrentCouncilId Long councilId, + @RequestBody @Valid NoticeRequest dto + ) { + NoticeResponse response = noticeService.update(councilId, noticeId, dto); + + return CommonResponse.success(NoticeResponseCode.NOTICE_UPDATE_SUCCESS, response); + } + + @DeleteMapping("/{noticeId}") + @PreAuthorize("hasRole('COUNCIL')") + @Operation(summary = "학생회 공지 삭제") + public CommonResponse delete( + @PathVariable Long noticeId, + @CurrentCouncilId Long councilId + ) { + noticeService.delete(councilId, noticeId); + + return CommonResponse.success(NoticeResponseCode.NOTICE_DELETE_SUCCESS); + } + + @GetMapping("/{noticeId}") + @Operation(summary = "학생회 공지 단건 조회") + public CommonResponse getNotice( + @PathVariable Long noticeId, + @CurrentCouncilId(required = false) Long councilId + ) { + NoticeResponse response = noticeService.findById(noticeId, councilId); + + return CommonResponse.success(NoticeResponseCode.NOTICE_READ_SUCCESS, response); + } +} diff --git a/src/main/java/com/campus/campus/domain/councilpost/application/dto/request/PostRequestDto.java b/src/main/java/com/campus/campus/domain/councilpost/application/dto/request/PostRequest.java similarity index 96% rename from src/main/java/com/campus/campus/domain/councilpost/application/dto/request/PostRequestDto.java rename to src/main/java/com/campus/campus/domain/councilpost/application/dto/request/PostRequest.java index 262711e..f7ac0be 100644 --- a/src/main/java/com/campus/campus/domain/councilpost/application/dto/request/PostRequestDto.java +++ b/src/main/java/com/campus/campus/domain/councilpost/application/dto/request/PostRequest.java @@ -11,7 +11,7 @@ import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; -public record PostRequestDto( +public record PostRequest( @NotNull PostCategory category, diff --git a/src/main/java/com/campus/campus/domain/councilpost/application/dto/response/PostListItemResponseDto.java b/src/main/java/com/campus/campus/domain/councilpost/application/dto/response/PostListItemResponse.java similarity index 91% rename from src/main/java/com/campus/campus/domain/councilpost/application/dto/response/PostListItemResponseDto.java rename to src/main/java/com/campus/campus/domain/councilpost/application/dto/response/PostListItemResponse.java index 8c42a32..7cf83e0 100644 --- a/src/main/java/com/campus/campus/domain/councilpost/application/dto/response/PostListItemResponseDto.java +++ b/src/main/java/com/campus/campus/domain/councilpost/application/dto/response/PostListItemResponse.java @@ -5,7 +5,7 @@ import com.campus.campus.domain.councilpost.domain.entity.PostCategory; import com.campus.campus.domain.councilpost.domain.entity.ThumbnailIcon; -public record PostListItemResponseDto( +public record PostListItemResponse( Long id, PostCategory category, String title, diff --git a/src/main/java/com/campus/campus/domain/councilpost/application/dto/response/PostResponseDto.java b/src/main/java/com/campus/campus/domain/councilpost/application/dto/response/PostResponse.java similarity index 95% rename from src/main/java/com/campus/campus/domain/councilpost/application/dto/response/PostResponseDto.java rename to src/main/java/com/campus/campus/domain/councilpost/application/dto/response/PostResponse.java index 9dbfe6a..fac5ace 100644 --- a/src/main/java/com/campus/campus/domain/councilpost/application/dto/response/PostResponseDto.java +++ b/src/main/java/com/campus/campus/domain/councilpost/application/dto/response/PostResponse.java @@ -12,7 +12,7 @@ @Builder @JsonInclude(JsonInclude.Include.NON_NULL) -public record PostResponseDto( +public record PostResponse( Long id, Long writerId, diff --git a/src/main/java/com/campus/campus/domain/councilpost/application/exception/ErrorCode.java b/src/main/java/com/campus/campus/domain/councilpost/application/exception/ErrorCode.java index c727454..92d00f6 100644 --- a/src/main/java/com/campus/campus/domain/councilpost/application/exception/ErrorCode.java +++ b/src/main/java/com/campus/campus/domain/councilpost/application/exception/ErrorCode.java @@ -13,13 +13,15 @@ public enum ErrorCode implements ErrorCodeInterface { NOT_POST_WRITER(2402, HttpStatus.FORBIDDEN, "작성자만 해당 작업을 수행할 수 있습니다."), THUMBNAIL_REQUIRED(2403, HttpStatus.BAD_REQUEST, "썸네일(이미지 또는 아이콘)은 반드시 필요합니다."), POST_IMAGE_LIMIT_EXCEEDED(2404, HttpStatus.BAD_REQUEST, "게시글 이미지는 최대 10개까지 등록할 수 있습니다."), + POST_OCI_IMAGE_DELETE_FAILED(2405, HttpStatus.INTERNAL_SERVER_ERROR, "OCI 이미지 삭제에 실패했습니다."), // EVENT 관련 - EVENT_START_DATETIME_REQUIRED(2405, HttpStatus.BAD_REQUEST, "행사는 시작 일시가 필요합니다."), - EVENT_END_DATETIME_NOT_ALLOWED(2406, HttpStatus.BAD_REQUEST, "행사는 종료 일시를 가질 수 없습니다."), + EVENT_START_DATETIME_REQUIRED(2406, HttpStatus.BAD_REQUEST, "행사는 시작 일시가 필요합니다."), + EVENT_END_DATETIME_NOT_ALLOWED(2407, HttpStatus.BAD_REQUEST, "행사는 종료 일시를 가질 수 없습니다."), // PARTNERSHIP 관련 - PARTNERSHIP_DATE_REQUIRED(2407, HttpStatus.BAD_REQUEST, "제휴는 시작일과 종료일이 필요합니다."); + PARTNERSHIP_DATE_REQUIRED(2408, HttpStatus.BAD_REQUEST, "제휴는 시작일과 종료일이 필요합니다."); + private final int code; private final HttpStatus status; diff --git a/src/main/java/com/campus/campus/domain/councilpost/application/exception/PostOciImageDeleteFailedException.java b/src/main/java/com/campus/campus/domain/councilpost/application/exception/PostOciImageDeleteFailedException.java new file mode 100644 index 0000000..4f7e030 --- /dev/null +++ b/src/main/java/com/campus/campus/domain/councilpost/application/exception/PostOciImageDeleteFailedException.java @@ -0,0 +1,9 @@ +package com.campus.campus.domain.councilpost.application.exception; + +import com.campus.campus.global.common.exception.ApplicationException; + +public class PostOciImageDeleteFailedException extends ApplicationException { + public PostOciImageDeleteFailedException() { + super(ErrorCode.POST_OCI_IMAGE_DELETE_FAILED); + } +} diff --git a/src/main/java/com/campus/campus/domain/councilpost/application/mapper/StudentCouncilPostMapper.java b/src/main/java/com/campus/campus/domain/councilpost/application/mapper/StudentCouncilPostMapper.java index e499fed..dac2bda 100644 --- a/src/main/java/com/campus/campus/domain/councilpost/application/mapper/StudentCouncilPostMapper.java +++ b/src/main/java/com/campus/campus/domain/councilpost/application/mapper/StudentCouncilPostMapper.java @@ -4,35 +4,43 @@ import java.util.Collections; import java.util.List; +import org.springframework.stereotype.Component; + import com.campus.campus.domain.council.domain.entity.StudentCouncil; -import com.campus.campus.domain.councilpost.application.dto.response.PostListItemResponseDto; -import com.campus.campus.domain.councilpost.application.dto.request.PostRequestDto; -import com.campus.campus.domain.councilpost.application.dto.response.PostResponseDto; +import com.campus.campus.domain.councilpost.application.dto.response.PostListItemResponse; +import com.campus.campus.domain.councilpost.application.dto.request.PostRequest; +import com.campus.campus.domain.councilpost.application.dto.response.PostResponse; import com.campus.campus.domain.councilpost.domain.entity.PostImage; import com.campus.campus.domain.councilpost.domain.entity.StudentCouncilPost; +import lombok.RequiredArgsConstructor; + +@Component +@RequiredArgsConstructor public class StudentCouncilPostMapper { - public static PostListItemResponseDto toListItem(StudentCouncilPost post, Long currentUserId) { - return new PostListItemResponseDto( + public PostListItemResponse toPostListItemResponse(StudentCouncilPost post, Long currentUserId) { + return new PostListItemResponse( post.getId(), post.getCategory(), post.getTitle(), post.getPlace(), - post.getEndDateTime(), + post.isEvent() + ? post.getStartDateTime() + : post.getEndDateTime(), post.getThumbnailImageUrl(), post.getThumbnailIcon(), - post.getWriter().getId().equals(currentUserId) + post.isWrittenByCouncil(currentUserId) ); } - public static PostResponseDto toDetail(StudentCouncilPost post, List images, Long currentUserId) { + public PostResponse toPostResponse(StudentCouncilPost post, List images, Long currentUserId) { var writer = post.getWriter(); - var builder = PostResponseDto.builder() + var builder = PostResponse.builder() .id(post.getId()) .writerId(writer.getId()) .writerName(writer.getFullCouncilName()) - .isWriter(post.isWrittenBy(currentUserId)) + .isWriter(post.isWrittenByCouncil(currentUserId)) .category(post.getCategory()) .title(post.getTitle()) .content(post.getContent()) @@ -51,7 +59,7 @@ public static PostResponseDto toDetail(StudentCouncilPost post, List ima return builder.build(); } - public static StudentCouncilPost toEntity(StudentCouncil writer, PostRequestDto dto, LocalDateTime startDateTime, + public StudentCouncilPost createStudentCouncilPost(StudentCouncil writer, PostRequest dto, LocalDateTime startDateTime, LocalDateTime endDateTime) { return StudentCouncilPost.builder() .writer(writer) @@ -66,7 +74,7 @@ public static StudentCouncilPost toEntity(StudentCouncil writer, PostRequestDto .build(); } - public static PostImage toEntity(StudentCouncilPost post, String imageUrl) { + public PostImage createPostImage(StudentCouncilPost post, String imageUrl) { return PostImage.builder() .post(post) .imageUrl(imageUrl) diff --git a/src/main/java/com/campus/campus/domain/councilpost/application/service/StudentCouncilPostService.java b/src/main/java/com/campus/campus/domain/councilpost/application/service/StudentCouncilPostService.java index de24292..f4845f0 100644 --- a/src/main/java/com/campus/campus/domain/councilpost/application/service/StudentCouncilPostService.java +++ b/src/main/java/com/campus/campus/domain/councilpost/application/service/StudentCouncilPostService.java @@ -1,5 +1,6 @@ package com.campus.campus.domain.councilpost.application.service; +import java.time.LocalDateTime; import java.util.ArrayList; import java.util.List; @@ -14,12 +15,13 @@ import com.campus.campus.domain.council.domain.entity.StudentCouncil; import com.campus.campus.domain.council.domain.repository.StudentCouncilRepository; import com.campus.campus.domain.councilpost.application.dto.response.NormalizedDateTime; -import com.campus.campus.domain.councilpost.application.dto.response.PostListItemResponseDto; -import com.campus.campus.domain.councilpost.application.dto.request.PostRequestDto; -import com.campus.campus.domain.councilpost.application.dto.response.PostResponseDto; +import com.campus.campus.domain.councilpost.application.dto.response.PostListItemResponse; +import com.campus.campus.domain.councilpost.application.dto.request.PostRequest; +import com.campus.campus.domain.councilpost.application.dto.response.PostResponse; import com.campus.campus.domain.councilpost.application.exception.NotPostWriterException; import com.campus.campus.domain.councilpost.application.exception.PostImageLimitExceededException; import com.campus.campus.domain.councilpost.application.exception.PostNotFoundException; +import com.campus.campus.domain.councilpost.application.exception.PostOciImageDeleteFailedException; import com.campus.campus.domain.councilpost.application.exception.ThumbnailRequiredException; import com.campus.campus.domain.councilpost.application.mapper.StudentCouncilPostMapper; import com.campus.campus.domain.councilpost.domain.entity.PostCategory; @@ -41,10 +43,14 @@ public class StudentCouncilPostService { private final StudentCouncilRepository studentCouncilRepository; private final PostImageRepository postImageRepository; private final PresignedUrlService presignedUrlService; + private final StudentCouncilPostMapper studentCouncilPostMapper; + + private static final int MAX_IMAGE_COUNT = 10; + private static final long UPCOMING_EVENT_WINDOW_HOURS = 72L; @Transactional - public PostResponseDto create(Long councilId, PostRequestDto dto) { - if (dto.imageUrls() != null && dto.imageUrls().size() > 10) { + public PostResponse create(Long councilId, PostRequest dto) { + if (dto.imageUrls() != null && dto.imageUrls().size() > MAX_IMAGE_COUNT) { throw new PostImageLimitExceededException(); } @@ -57,7 +63,7 @@ public PostResponseDto create(Long councilId, PostRequestDto dto) { NormalizedDateTime normalized = dto.category().validateAndNormalize(dto); - StudentCouncilPost post = StudentCouncilPostMapper.toEntity( + StudentCouncilPost post = studentCouncilPostMapper.createStudentCouncilPost( writer, dto, normalized.startDateTime(), normalized.endDateTime() ); @@ -65,7 +71,7 @@ public PostResponseDto create(Long councilId, PostRequestDto dto) { if (dto.imageUrls() != null) { for (String imageUrl : dto.imageUrls()) { - postImageRepository.save(StudentCouncilPostMapper.toEntity(post, imageUrl)); + postImageRepository.save(studentCouncilPostMapper.createPostImage(post, imageUrl)); } } @@ -75,11 +81,11 @@ public PostResponseDto create(Long councilId, PostRequestDto dto) { .map(PostImage::getImageUrl) .toList(); - return StudentCouncilPostMapper.toDetail(post, imageUrls, councilId); + return studentCouncilPostMapper.toPostResponse(post, imageUrls, councilId); } @Transactional(readOnly = true) - public PostResponseDto findById(Long postId, Long currentUserId) { + public PostResponse findById(Long postId, Long currentUserId) { StudentCouncilPost post = postRepository.findByIdWithFullInfo(postId) .orElseThrow(PostNotFoundException::new); @@ -89,11 +95,11 @@ public PostResponseDto findById(Long postId, Long currentUserId) { .map(PostImage::getImageUrl) .toList(); - return StudentCouncilPostMapper.toDetail(post, imageUrls, currentUserId); + return studentCouncilPostMapper.toPostResponse(post, imageUrls, currentUserId); } @Transactional(readOnly = true) - public Page findAll(PostCategory category, int page, int size, Long currentUserId) { + public Page findAll(PostCategory category, int page, int size, Long currentUserId) { Pageable pageable = PageRequest.of(Math.max(page - 1, 0), size, Sort.by(Sort.Direction.DESC, "createdAt")); Page posts = (category == null) @@ -101,13 +107,31 @@ public Page findAll(PostCategory category, int page, in : postRepository.findAllByCategory(category, pageable); return posts.map(post -> - StudentCouncilPostMapper.toListItem(post, currentUserId) + studentCouncilPostMapper.toPostListItemResponse(post, currentUserId) + ); + } + + @Transactional(readOnly = true) + public Page findUpcomingEvents(int page, int size, Long currentUserId) { + Pageable pageable = PageRequest.of( + Math.max(page - 1, 0), + size, + Sort.by(Sort.Direction.ASC, "startDateTime") + ); + + LocalDateTime now = LocalDateTime.now(); + LocalDateTime limit = now.plusHours(UPCOMING_EVENT_WINDOW_HOURS); + + Page posts = postRepository.findUpcomingEvents(PostCategory.EVENT, now, limit, pageable); + + return posts.map(post -> + studentCouncilPostMapper.toPostListItemResponse(post, currentUserId) ); } @Transactional public void delete(Long councilId, Long postId) { - StudentCouncilPost post = postRepository.findByIdWithWriter(postId) + StudentCouncilPost post = postRepository.findByIdWithFullInfo(postId) .orElseThrow(PostNotFoundException::new); if (!post.getWriter().getId().equals(councilId)) { @@ -132,14 +156,14 @@ public void delete(Long councilId, Long postId) { for (String imageUrl : deleteTargets) { try { presignedUrlService.deleteImage(imageUrl); - } catch (Exception e) { - log.warn("OCI 파일 삭제 실패: {}", imageUrl); + } catch (PostOciImageDeleteFailedException e) { + log.warn("OCI 파일 삭제 실패: {}", imageUrl, e); } } } @Transactional - public PostResponseDto update(Long councilId, Long postId, PostRequestDto dto) { + public PostResponse update(Long councilId, Long postId, PostRequest dto) { if (dto.imageUrls() != null && dto.imageUrls().size() > 10) { throw new PostImageLimitExceededException(); } @@ -176,7 +200,7 @@ public PostResponseDto update(Long councilId, Long postId, PostRequestDto dto) { if (dto.imageUrls() != null) { for (String imageUrl : dto.imageUrls()) { - postImageRepository.save(StudentCouncilPostMapper.toEntity(post, imageUrl)); + postImageRepository.save(studentCouncilPostMapper.createPostImage(post, imageUrl)); } } @@ -188,11 +212,11 @@ public PostResponseDto update(Long councilId, Long postId, PostRequestDto dto) { .map(PostImage::getImageUrl) .toList(); - return StudentCouncilPostMapper.toDetail(post, imageUrls, councilId); + return studentCouncilPostMapper.toPostResponse(post, imageUrls, councilId); } //이미지 삭제 - private void cleanupUnusedImages(String oldThumbnailUrl, List oldImages, PostRequestDto dto) { + private void cleanupUnusedImages(String oldThumbnailUrl, List oldImages, PostRequest dto) { List newUrls = dto.imageUrls() == null ? List.of() : dto.imageUrls(); List deleteTargets = new ArrayList<>(); @@ -216,8 +240,8 @@ private void cleanupUnusedImages(String oldThumbnailUrl, List oldImag try { presignedUrlService.deleteImage(imageUrl); - } catch (Exception e) { - log.warn("OCI 파일 삭제 실패 (파일이 없을 수 있음): {}", imageUrl); + } catch (PostOciImageDeleteFailedException e) { + log.warn("OCI 파일 삭제 실패 (파일이 없을 수 있음): {}", imageUrl, e); } } } diff --git a/src/main/java/com/campus/campus/domain/councilpost/domain/entity/PostCategory.java b/src/main/java/com/campus/campus/domain/councilpost/domain/entity/PostCategory.java index 1df026c..a2a306f 100644 --- a/src/main/java/com/campus/campus/domain/councilpost/domain/entity/PostCategory.java +++ b/src/main/java/com/campus/campus/domain/councilpost/domain/entity/PostCategory.java @@ -3,7 +3,7 @@ import java.time.LocalTime; import com.campus.campus.domain.councilpost.application.dto.response.NormalizedDateTime; -import com.campus.campus.domain.councilpost.application.dto.request.PostRequestDto; +import com.campus.campus.domain.councilpost.application.dto.request.PostRequest; import com.campus.campus.domain.councilpost.application.exception.EventEndDateTimeNotAllowedException; import com.campus.campus.domain.councilpost.application.exception.EventStartDateTimeRequiredException; import com.campus.campus.domain.councilpost.application.exception.PartnershipDateRequiredException; @@ -12,7 +12,7 @@ public enum PostCategory { EVENT { @Override - public NormalizedDateTime validateAndNormalize(PostRequestDto dto) { + public NormalizedDateTime validateAndNormalize(PostRequest dto) { if (dto.startDateTime() == null) { throw new EventStartDateTimeRequiredException(); } @@ -25,7 +25,7 @@ public NormalizedDateTime validateAndNormalize(PostRequestDto dto) { PARTNERSHIP { @Override - public NormalizedDateTime validateAndNormalize(PostRequestDto dto) { + public NormalizedDateTime validateAndNormalize(PostRequest dto) { if (dto.startDateTime() == null || dto.endDateTime() == null) { throw new PartnershipDateRequiredException(); } @@ -36,5 +36,5 @@ public NormalizedDateTime validateAndNormalize(PostRequestDto dto) { } }; - public abstract NormalizedDateTime validateAndNormalize(PostRequestDto dto); + public abstract NormalizedDateTime validateAndNormalize(PostRequest dto); } diff --git a/src/main/java/com/campus/campus/domain/councilpost/domain/entity/StudentCouncilPost.java b/src/main/java/com/campus/campus/domain/councilpost/domain/entity/StudentCouncilPost.java index a4d2c5c..60b34db 100644 --- a/src/main/java/com/campus/campus/domain/councilpost/domain/entity/StudentCouncilPost.java +++ b/src/main/java/com/campus/campus/domain/councilpost/domain/entity/StudentCouncilPost.java @@ -91,8 +91,8 @@ public LocalDate getDisplayEndDate() { return endDateTime != null ? endDateTime.toLocalDate() : null; } - public boolean isWrittenBy(Long userId) { - return writer != null && writer.getId().equals(userId); + public boolean isWrittenByCouncil(Long councilId) { + return writer != null && writer.getId().equals(councilId); } } diff --git a/src/main/java/com/campus/campus/domain/councilpost/domain/repository/StudentCouncilPostRepository.java b/src/main/java/com/campus/campus/domain/councilpost/domain/repository/StudentCouncilPostRepository.java index c10f268..8551757 100644 --- a/src/main/java/com/campus/campus/domain/councilpost/domain/repository/StudentCouncilPostRepository.java +++ b/src/main/java/com/campus/campus/domain/councilpost/domain/repository/StudentCouncilPostRepository.java @@ -1,5 +1,6 @@ package com.campus.campus.domain.councilpost.domain.repository; +import java.time.LocalDateTime; import java.util.Optional; import org.springframework.data.domain.Page; @@ -23,12 +24,17 @@ public interface StudentCouncilPostRepository extends JpaRepository findByIdWithFullInfo(@Param("postId") Long postId); - @Query("SELECT p FROM StudentCouncilPost p " + - "JOIN FETCH p.writer w " + - "LEFT JOIN FETCH w.school " + - "LEFT JOIN FETCH w.college " + - "LEFT JOIN FETCH w.major " + - "WHERE p.id = :postId") - Optional findByIdWithWriter(@Param("postId") Long postId); + @Query(""" + SELECT p + FROM StudentCouncilPost p + WHERE p.category = :category + AND p.startDateTime BETWEEN :now AND :limit + """) + Page findUpcomingEvents( + @Param("category") PostCategory category, + @Param("now") LocalDateTime now, + @Param("limit") LocalDateTime limit, + Pageable pageable + ); } diff --git a/src/main/java/com/campus/campus/domain/councilpost/presentation/StudentCouncilPostController.java b/src/main/java/com/campus/campus/domain/councilpost/presentation/StudentCouncilPostController.java index 2da5157..70db8fb 100644 --- a/src/main/java/com/campus/campus/domain/councilpost/presentation/StudentCouncilPostController.java +++ b/src/main/java/com/campus/campus/domain/councilpost/presentation/StudentCouncilPostController.java @@ -12,9 +12,9 @@ import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; -import com.campus.campus.domain.councilpost.application.dto.request.PostRequestDto; -import com.campus.campus.domain.councilpost.application.dto.response.PostListItemResponseDto; -import com.campus.campus.domain.councilpost.application.dto.response.PostResponseDto; +import com.campus.campus.domain.councilpost.application.dto.request.PostRequest; +import com.campus.campus.domain.councilpost.application.dto.response.PostListItemResponse; +import com.campus.campus.domain.councilpost.application.dto.response.PostResponse; import com.campus.campus.domain.councilpost.application.service.StudentCouncilPostService; import com.campus.campus.domain.councilpost.domain.entity.PostCategory; import com.campus.campus.global.annotation.CurrentCouncilId; @@ -65,7 +65,7 @@ public class StudentCouncilPostController { required = true, content = @Content( mediaType = "application/json", - schema = @Schema(implementation = PostRequestDto.class), + schema = @Schema(implementation = PostRequest.class), examples = { @ExampleObject( name = "EVENT 게시글", @@ -102,18 +102,40 @@ public class StudentCouncilPostController { ) ) ) - public CommonResponse createPost( + public CommonResponse createPost( @CurrentCouncilId Long councilId, - @RequestBody @Valid PostRequestDto requestDto + @RequestBody @Valid PostRequest requestDto ) { - PostResponseDto responseDto = postService.create(councilId, requestDto); + PostResponse responseDto = postService.create(councilId, requestDto); return CommonResponse.success(StudentCouncilPostResponseCode.POST_CREATE_SUCCESS, responseDto); } + @PatchMapping("/{postId}") + @PreAuthorize("hasRole('COUNCIL')") + @Operation(summary = "학생회 게시글 수정") + public CommonResponse updatePost( + @CurrentCouncilId Long councilId, + @PathVariable Long postId, + @RequestBody @Valid PostRequest requestDto + ) { + PostResponse responseDto = postService.update(councilId, postId, requestDto); + + return CommonResponse.success(StudentCouncilPostResponseCode.POST_UPDATE_SUCCESS, responseDto); + } + + @DeleteMapping("/{postId}") + @PreAuthorize("hasRole('COUNCIL')") + @Operation(summary = "학생회 게시글 삭제") + public CommonResponse deletePost(@CurrentCouncilId Long councilId, @PathVariable Long postId) { + postService.delete(councilId, postId); + + return CommonResponse.success(StudentCouncilPostResponseCode.POST_DELETE_SUCCESS); + } + @GetMapping("/{postId}") @Operation(summary = "학생회 게시글 단건 조회") - public CommonResponse getPost(@PathVariable Long postId, @CurrentCouncilId Long councilId) { - PostResponseDto responseDto = + public CommonResponse getPost(@PathVariable Long postId, @CurrentCouncilId Long councilId) { + PostResponse responseDto = postService.findById(postId, councilId); return CommonResponse.success(StudentCouncilPostResponseCode.POST_READ_SUCCESS, responseDto); @@ -124,7 +146,7 @@ public CommonResponse getPost(@PathVariable Long postId, @Curre summary = "학생회 게시글 목록 조회 (필터링 포함)", description = "전체 게시글 혹은 제휴(PARTNERSHIP), 행사(EVENT) 카테고리별로 필터링하여 목록을 조회합니다." ) - public CommonResponse> getPostList( + public CommonResponse> getPostList( @Parameter( description = "필터링할 카테고리 (미선택 시 전체 조회)", example = "PARTNERSHIP", @@ -133,32 +155,22 @@ public CommonResponse> getPostList( @RequestParam(required = false) PostCategory category, @RequestParam(defaultValue = "1") int page, @RequestParam(defaultValue = "3") int size, - @CurrentCouncilId Long councilId + @CurrentCouncilId(required = false) Long councilId ) { - Page responseDto = postService.findAll(category, page, size, councilId); + Page responseDto = postService.findAll(category, page, size, councilId); return CommonResponse.success(StudentCouncilPostResponseCode.POST_LIST_READ_SUCCESS, responseDto); } - @PatchMapping("/{postId}") - @PreAuthorize("hasRole('COUNCIL')") - @Operation(summary = "학생회 게시글 수정") - public CommonResponse updatePost( - @CurrentCouncilId Long councilId, - @PathVariable Long postId, - @RequestBody @Valid PostRequestDto requestDto + @GetMapping("/events/upcoming") + @Operation(summary = "72시간 이내 행사 게시글 조회") + public CommonResponse> getUpcomingEvents( + @RequestParam(defaultValue = "1") int page, + @RequestParam(defaultValue = "3") int size, + @CurrentCouncilId(required = false) Long councilId ) { - PostResponseDto responseDto = postService.update(councilId, postId, requestDto); - - return CommonResponse.success(StudentCouncilPostResponseCode.POST_UPDATE_SUCCESS, responseDto); - } - - @DeleteMapping("/{postId}") - @PreAuthorize("hasRole('COUNCIL')") - @Operation(summary = "학생회 게시글 삭제") - public CommonResponse deletePost(@CurrentCouncilId Long councilId, @PathVariable Long postId) { - postService.delete(councilId, postId); - - return CommonResponse.success(StudentCouncilPostResponseCode.POST_DELETE_SUCCESS); + return CommonResponse.success(StudentCouncilPostResponseCode.POST_LIST_READ_SUCCESS, + postService.findUpcomingEvents(page, size, councilId) + ); } } diff --git a/src/main/java/com/campus/campus/domain/councilpost/presentation/StudentCouncilPostResponseCode.java b/src/main/java/com/campus/campus/domain/councilpost/presentation/StudentCouncilPostResponseCode.java index f33fe37..dd7aaef 100644 --- a/src/main/java/com/campus/campus/domain/councilpost/presentation/StudentCouncilPostResponseCode.java +++ b/src/main/java/com/campus/campus/domain/councilpost/presentation/StudentCouncilPostResponseCode.java @@ -15,8 +15,7 @@ public enum StudentCouncilPostResponseCode implements ResponseCodeInterface { POST_READ_SUCCESS(200, HttpStatus.OK, "게시글 조회에 성공했습니다."), POST_LIST_READ_SUCCESS(200, HttpStatus.OK, "게시글 목록 조회에 성공했습니다."), POST_UPDATE_SUCCESS(200, HttpStatus.OK, "게시글 수정에 성공했습니다."), - POST_DELETE_SUCCESS(204, HttpStatus.NO_CONTENT, "게시글 삭제에 성공했습니다."), - POST_IMAGE_FINALIZE_SUCCESS(200, HttpStatus.OK, "이미지 확정(이동)에 성공했습니다."); + POST_DELETE_SUCCESS(200, HttpStatus.OK, "게시글 삭제에 성공했습니다."); private final int code; private final HttpStatus status; diff --git a/src/main/java/com/campus/campus/global/oci/exception/ErrorCode.java b/src/main/java/com/campus/campus/global/oci/exception/ErrorCode.java index babe000..91d90f1 100644 --- a/src/main/java/com/campus/campus/global/oci/exception/ErrorCode.java +++ b/src/main/java/com/campus/campus/global/oci/exception/ErrorCode.java @@ -12,9 +12,7 @@ public enum ErrorCode implements ErrorCodeInterface { OCI_PRESIGNED_URL_CREATE_FAILED(6100, HttpStatus.INTERNAL_SERVER_ERROR, "Presigned URL 생성에 실패했습니다."), - OCI_OBJECT_COPY_FAILED(6101, HttpStatus.INTERNAL_SERVER_ERROR, "이미지 복사 중 오류가 발생했습니다."), - OCI_OBJECT_DELETE_FAILED(6102, HttpStatus.INTERNAL_SERVER_ERROR, "임시 이미지 삭제 중 오류가 발생했습니다."), - OCI_IMAGE_MOVE_FAILED(6103, HttpStatus.INTERNAL_SERVER_ERROR, "이미지를 최종 경로로 이동하는 데 실패했습니다."); + OCI_OBJECT_DELETE_FAILED(6101, HttpStatus.INTERNAL_SERVER_ERROR, "이미지 삭제 중 오류가 발생했습니다."); private final int code; private final HttpStatus status; diff --git a/src/main/java/com/campus/campus/global/oci/mapper/PresignedUrlMapper.java b/src/main/java/com/campus/campus/global/oci/mapper/PresignedUrlMapper.java index 5ce3e70..a3b932d 100644 --- a/src/main/java/com/campus/campus/global/oci/mapper/PresignedUrlMapper.java +++ b/src/main/java/com/campus/campus/global/oci/mapper/PresignedUrlMapper.java @@ -3,10 +3,16 @@ import java.util.Date; import java.util.UUID; +import org.springframework.stereotype.Component; + import com.oracle.bmc.objectstorage.model.CreatePreauthenticatedRequestDetails; import com.oracle.bmc.objectstorage.requests.CreatePreauthenticatedRequestRequest; import com.oracle.bmc.objectstorage.requests.DeleteObjectRequest; +import lombok.RequiredArgsConstructor; + +@Component +@RequiredArgsConstructor public class PresignedUrlMapper { public static CreatePreauthenticatedRequestRequest toPutObjectRequest(String bucketName, String namespaceName, diff --git a/src/main/java/com/campus/campus/global/oci/presentation/PresignedUrlController.java b/src/main/java/com/campus/campus/global/oci/presentation/PresignedUrlController.java index d84d742..14092e9 100644 --- a/src/main/java/com/campus/campus/global/oci/presentation/PresignedUrlController.java +++ b/src/main/java/com/campus/campus/global/oci/presentation/PresignedUrlController.java @@ -46,4 +46,16 @@ public CommonResponse createPostImagePresignedUrl( return CommonResponse.success(PresignedUrlResponseCode.PRESIGNED_URL_SUCCESS, responseDto); } + @PostMapping("/notices/images/presigned") + @Operation( + summary = "공지 이미지 업로드용 Presigned URL 생성", + description = "공지 이미지 업로드 전용 Presigned URL을 생성" + ) + public CommonResponse createNoticeImagePresignedUrl( + @RequestBody @Valid PresignedUrlRequestDto request + ) { + PresignedUrlResponseDto responseDto = presignedUrlService.createPresignedUrl("notices/images", request); + + return CommonResponse.success(PresignedUrlResponseCode.PRESIGNED_URL_SUCCESS, responseDto); + } }