-
Notifications
You must be signed in to change notification settings - Fork 0
Feat/#23 학생회 공지 게시글 생성/조회/수정/삭제 기능 및 72시간 이내 행사 조회 기능 구현 #24
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 5 commits
a847007
e8353e9
3743988
7979c6c
6e6d537
f6f71ad
c317e7c
b1d9144
500f53d
d931988
b2b3f68
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,10 @@ | ||
| package com.campus.campus.domain.councilNotice.application.dto.request; | ||
|
|
||
| import java.util.List; | ||
|
|
||
| public record NoticeRequestDto( | ||
| String title, | ||
| String content, | ||
| List<String> imageUrls | ||
| ) { | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,15 @@ | ||
| package com.campus.campus.domain.councilNotice.application.dto.response; | ||
|
|
||
| import java.time.LocalDateTime; | ||
|
|
||
| import lombok.Builder; | ||
|
|
||
| @Builder | ||
| public record NoticeListItemResponseDto( | ||
| Long id, | ||
| String title, | ||
| boolean isWriter, | ||
| LocalDateTime createdAt, | ||
| LocalDateTime updatedAt | ||
| ) { | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 NoticeResponseDto( | ||
| Long id, | ||
| Long writerId, | ||
| String writerName, | ||
| boolean isWriter, | ||
| String title, | ||
| String content, | ||
| List<String> images, | ||
| LocalDateTime createdAt, | ||
| LocalDateTime updatedAt | ||
| ) { | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,21 @@ | ||
| 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개까지 등록할 수 있습니다."); | ||
|
|
||
| private final int code; | ||
| private final HttpStatus status; | ||
| private final String message; | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,52 @@ | ||
| package com.campus.campus.domain.councilNotice.application.mapper; | ||
|
|
||
| import java.util.List; | ||
|
|
||
| import com.campus.campus.domain.council.domain.entity.StudentCouncil; | ||
| import com.campus.campus.domain.councilNotice.application.dto.request.NoticeRequestDto; | ||
| import com.campus.campus.domain.councilNotice.application.dto.response.NoticeListItemResponseDto; | ||
| import com.campus.campus.domain.councilNotice.application.dto.response.NoticeResponseDto; | ||
| import com.campus.campus.domain.councilNotice.domain.entity.NoticeImage; | ||
| import com.campus.campus.domain.councilNotice.domain.entity.StudentCouncilNotice; | ||
|
|
||
| public class StudentCouncilNoticeMapper { | ||
|
|
||
| public static StudentCouncilNotice toEntity(StudentCouncil writer, NoticeRequestDto dto) { | ||
| return StudentCouncilNotice.builder() | ||
| .title(dto.title()) | ||
| .content(dto.content()) | ||
| .writer(writer) | ||
| .build(); | ||
| } | ||
|
|
||
| public static NoticeImage toEntity(StudentCouncilNotice notice, String imageUrl) { | ||
| return NoticeImage.builder() | ||
| .notice(notice) | ||
| .imageUrl(imageUrl) | ||
| .build(); | ||
| } | ||
|
|
||
| public static NoticeResponseDto toDetail(StudentCouncilNotice notice, List<String> imageUrls, Long councilId) { | ||
| return NoticeResponseDto.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 static NoticeListItemResponseDto toListItem(StudentCouncilNotice notice, Long currentUserId) { | ||
| return NoticeListItemResponseDto.builder() | ||
| .id(notice.getId()) | ||
| .title(notice.getTitle()) | ||
| .isWriter(notice.isWrittenByCouncil(currentUserId)) | ||
| .createdAt(notice.getCreatedAt()) | ||
| .updatedAt(notice.getUpdatedAt()) | ||
| .build(); | ||
|
||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,201 @@ | ||
| package com.campus.campus.domain.councilNotice.application.service; | ||
|
|
||
| import java.util.ArrayList; | ||
| 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.NoticeRequestDto; | ||
| import com.campus.campus.domain.councilNotice.application.dto.response.NoticeListItemResponseDto; | ||
| import com.campus.campus.domain.councilNotice.application.dto.response.NoticeResponseDto; | ||
| 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.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; | ||
|
|
||
| @Transactional | ||
| public NoticeResponseDto create(Long councilId, NoticeRequestDto 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.toEntity(writer, dto)); | ||
|
|
||
| if (dto.imageUrls() != null && !dto.imageUrls().isEmpty()) { | ||
| List<NoticeImage> images = dto.imageUrls().stream() | ||
| .map(imageUrl -> StudentCouncilNoticeMapper.toEntity(notice, imageUrl)) | ||
| .toList(); | ||
|
|
||
| noticeImageRepository.saveAll(images); | ||
| } | ||
|
|
||
| List<String> imageUrls = noticeImageRepository | ||
| .findAllByNoticeOrderByIdAsc(notice) | ||
| .stream() | ||
| .map(NoticeImage::getImageUrl) | ||
| .toList(); | ||
|
|
||
| return StudentCouncilNoticeMapper.toDetail(notice, imageUrls, councilId); | ||
| } | ||
|
|
||
| @Transactional(readOnly = true) | ||
| public NoticeResponseDto findById(Long noticeId, Long councilId) { | ||
|
|
||
| StudentCouncilNotice notice = noticeRepository.findByIdWithFullInfo(noticeId) | ||
| .orElseThrow(NoticeNotFoundException::new); | ||
|
|
||
| List<String> imageUrls = noticeImageRepository | ||
| .findAllByNoticeOrderByIdAsc(notice) | ||
| .stream() | ||
| .map(NoticeImage::getImageUrl) | ||
| .toList(); | ||
|
|
||
| return StudentCouncilNoticeMapper.toDetail(notice, imageUrls, councilId); | ||
| } | ||
|
|
||
| @Transactional(readOnly = true) | ||
| public Page<NoticeListItemResponseDto> findAll(int page, int size, Long councilId) { | ||
|
|
||
| Pageable pageable = PageRequest.of( | ||
| Math.max(page - 1, 0), | ||
| size, | ||
| Sort.by(Sort.Direction.DESC, "createdAt") | ||
| ); | ||
|
|
||
| Page<StudentCouncilNotice> notices = noticeRepository.findAll(pageable); | ||
|
|
||
| return notices.map(notice -> | ||
| StudentCouncilNoticeMapper.toListItem(notice, councilId) | ||
| ); | ||
| } | ||
|
|
||
| @Transactional | ||
| public NoticeResponseDto update(Long councilId, Long noticeId, NoticeRequestDto 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<NoticeImage> 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.toEntity(notice, imageUrl)); | ||
| } | ||
| } | ||
|
|
||
| cleanupUnusedImages(oldImages, dto); | ||
|
|
||
| List<String> imageUrls = noticeImageRepository | ||
| .findAllByNoticeOrderByIdAsc(notice) | ||
| .stream() | ||
| .map(NoticeImage::getImageUrl) | ||
| .toList(); | ||
|
|
||
| return StudentCouncilNoticeMapper.toDetail(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<NoticeImage> images = noticeImageRepository.findAllByNotice(notice); | ||
|
|
||
| List<String> deleteTargets = new ArrayList<>(); | ||
| images.stream() | ||
| .map(NoticeImage::getImageUrl) | ||
| .forEach(deleteTargets::add); | ||
|
|
||
| noticeImageRepository.deleteAll(images); | ||
| noticeRepository.delete(notice); | ||
|
|
||
| for (String imageUrl : deleteTargets) { | ||
| if (imageUrl == null || imageUrl.isBlank()) { | ||
| continue; | ||
| } | ||
|
|
||
| try { | ||
| presignedUrlService.deleteImage(imageUrl); | ||
| } catch (Exception e) { | ||
| log.warn("OCI 파일 삭제 실패 (파일이 없을 수 있음): {}", imageUrl); | ||
| } | ||
| } | ||
| } | ||
|
|
||
| private void cleanupUnusedImages(List<NoticeImage> oldImages, NoticeRequestDto dto) { | ||
| List<String> newUrls = dto.imageUrls() == null ? List.of() : dto.imageUrls(); | ||
|
|
||
| List<String> deleteTargets = new ArrayList<>(); | ||
|
|
||
| // 본문 이미지 중 제거된 이미지 | ||
| oldImages.stream() | ||
| .map(NoticeImage::getImageUrl) | ||
| .filter(url -> !newUrls.contains(url)) | ||
| .forEach(deleteTargets::add); | ||
|
|
||
| //삭제 | ||
| for (String imageUrl : deleteTargets) { | ||
| if (imageUrl == null || imageUrl.isBlank()) { | ||
| continue; | ||
| } | ||
|
|
||
| try { | ||
| presignedUrlService.deleteImage(imageUrl); | ||
| } catch (Exception e) { | ||
| log.warn("OCI 파일 삭제 실패 (파일이 없을 수 있음): {}", imageUrl); | ||
| } | ||
| } | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,34 @@ | ||
| package com.campus.campus.domain.councilNotice.domain.entity; | ||
|
|
||
| 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 | ||
| @Builder | ||
| public class NoticeImage { | ||
|
||
|
|
||
| @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; | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
mapper에 관한 메서드 명명 규칙에 대한 통일이 필요해보여요!!
저의 경우 엔티티를 새로 생성해 저장하는 경우
createStudentCouncilNotice,createCouncil등 "create + 엔티티명"의 형태로 구현하며 응답 dto 경우toNoticeResponseDto,toUserResponse등으로 "to + 응답dto 이름"의 형태로 이름을 만듭니다.재영님의 경우
toEntity,toDetail등으로 메서드명을 통일하고 계시는 느낌인데 이는 모두가 동일한 명명법을 사용하는 방법을 생각해야할 것 같아요!!저의 경우 재영님과 같이 메서드명을 통일시키지 않은 이유는 추후 service 내 코드를 확인할 때 이름이 겹쳐 코드를 확인하고 이해하는데 혼동이 올 수 있다고 생각하여 각각 한번에 알아볼 수 있도록 하려 했기 때문입니다!!
재영님은 어떤 방식으로 메서드명을 짓는 것이 좋다고 생각하시나요??
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
저는 예를 들어 mapper의 toEntitiy가 꼭 create 하는 데에만 쓰이는 것이 아니라 update에도 쓰니까 create~이렇게 하게 되면 create할 때만 쓰이는 것인가?로 생각할 수도 있고 좀 더 뭐할 때에 쓰이는 지 명확하다고 생각을 해서 평소에는 그렇게 쓴 것이었습니다.
이 부분은 원래 먼저 올린 사람대로 규칙을 따라가자고 했어서 제가 고쳤어야 했는데, 승현님꺼 보고 나중에 고치자 하다가 깜빡했네요! 승현님 메서드명으로 바꾸겠습니다.