diff --git a/build.gradle b/build.gradle index 04b66f4..52848d1 100644 --- a/build.gradle +++ b/build.gradle @@ -59,6 +59,8 @@ dependencies { annotationProcessor 'org.projectlombok:lombok' testImplementation 'org.springframework.boot:spring-boot-starter-test' testRuntimeOnly 'org.junit.platform:junit-platform-launcher' + + implementation 'org.springframework.boot:spring-boot-starter-validation' // 유효성 검사 위해 } tasks.named('test') { diff --git a/src/main/java/sopt/univoice/domain/notice/controller/NoticeController.java b/src/main/java/sopt/univoice/domain/notice/controller/NoticeController.java index 0e7a367..ad5d977 100644 --- a/src/main/java/sopt/univoice/domain/notice/controller/NoticeController.java +++ b/src/main/java/sopt/univoice/domain/notice/controller/NoticeController.java @@ -1,6 +1,7 @@ package sopt.univoice.domain.notice.controller; +import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; @@ -20,7 +21,7 @@ public class NoticeController { private final NoticeService noticeService; @PostMapping("/create") - public ResponseEntity> createPost(@ModelAttribute NoticeCreateRequest noticeCreateRequest) { + public ResponseEntity> createPost(@Valid @ModelAttribute NoticeCreateRequest noticeCreateRequest) { System.out.println("createPost method called with request: " + noticeCreateRequest); noticeService.createPost(noticeCreateRequest); return ResponseEntity.status(HttpStatus.CREATED) diff --git a/src/main/java/sopt/univoice/domain/notice/dto/NoticeCreateRequest.java b/src/main/java/sopt/univoice/domain/notice/dto/NoticeCreateRequest.java index 64023d9..56e74aa 100644 --- a/src/main/java/sopt/univoice/domain/notice/dto/NoticeCreateRequest.java +++ b/src/main/java/sopt/univoice/domain/notice/dto/NoticeCreateRequest.java @@ -1,5 +1,6 @@ package sopt.univoice.domain.notice.dto; +import jakarta.validation.constraints.Size; import lombok.AllArgsConstructor; import lombok.*; import org.springframework.format.annotation.DateTimeFormat; @@ -15,7 +16,7 @@ //@AllArgsConstructor public class NoticeCreateRequest { - + @Size(max = 100) private String title; private String content; @@ -27,6 +28,6 @@ public class NoticeCreateRequest { @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) private LocalDateTime endTime; - private List studentCardImages; - + @Size(max = 5) + private List imageList; } diff --git a/src/main/java/sopt/univoice/domain/notice/dto/NoticeDetailResponseDTO.java b/src/main/java/sopt/univoice/domain/notice/dto/NoticeDetailResponseDTO.java index 9a2efd5..2ef938f 100644 --- a/src/main/java/sopt/univoice/domain/notice/dto/NoticeDetailResponseDTO.java +++ b/src/main/java/sopt/univoice/domain/notice/dto/NoticeDetailResponseDTO.java @@ -10,16 +10,13 @@ @AllArgsConstructor public class NoticeDetailResponseDTO { private Long id; + private String writeAffiliation; private String title; - private String content; - private Long noticeLike; - private Long viewCount; private String target; private LocalDateTime startTime; private LocalDateTime endTime; - private String category; - private String contentSummary; - private Long memberId; - private String writeAffiliation; + private String content; + private LocalDateTime createdAt; + private Long viewCount; private List noticeImages; } diff --git a/src/main/java/sopt/univoice/domain/notice/dto/NoticeSaveDTO.java b/src/main/java/sopt/univoice/domain/notice/dto/NoticeSaveDTO.java index 9a1f0fd..baaf94f 100644 --- a/src/main/java/sopt/univoice/domain/notice/dto/NoticeSaveDTO.java +++ b/src/main/java/sopt/univoice/domain/notice/dto/NoticeSaveDTO.java @@ -13,6 +13,6 @@ public class NoticeSaveDTO { private Long viewCount; private Long noticeLike; private String category; - private LocalDateTime startTime; - private LocalDateTime endTime; + private LocalDateTime createdAt; + private String image; } diff --git a/src/main/java/sopt/univoice/domain/notice/entity/Notice.java b/src/main/java/sopt/univoice/domain/notice/entity/Notice.java index 52d250c..6dbd535 100644 --- a/src/main/java/sopt/univoice/domain/notice/entity/Notice.java +++ b/src/main/java/sopt/univoice/domain/notice/entity/Notice.java @@ -30,8 +30,12 @@ public class Notice extends BaseTimeEntity { private Long noticeLike = 0L; private Long viewCount = 0L; + + private Long noticeSave = 0L; + private String target; + private LocalDateTime startTime; private LocalDateTime endTime; @@ -71,6 +75,7 @@ public Notice(String title, String content, String target, LocalDateTime startTi this.member = member; this.noticeLike = 0L; this.viewCount = 0L; + this.noticeSave = 0L; this.contentSummary = contentSummary; this.category = category; } @@ -89,6 +94,9 @@ public Long getViewCount() { return viewCount; } + public void setNoticeSave(Long noticeSave) { + this.noticeSave = noticeSave; + } } diff --git a/src/main/java/sopt/univoice/domain/notice/entity/SaveNotice.java b/src/main/java/sopt/univoice/domain/notice/entity/SaveNotice.java index b57b163..eb8e6f4 100644 --- a/src/main/java/sopt/univoice/domain/notice/entity/SaveNotice.java +++ b/src/main/java/sopt/univoice/domain/notice/entity/SaveNotice.java @@ -5,12 +5,13 @@ import lombok.Getter; import lombok.NoArgsConstructor; import sopt.univoice.domain.user.entity.Member; +import sopt.univoice.infra.persistence.BaseTimeEntity; @Entity @Getter @NoArgsConstructor -public class SaveNotice { +public class SaveNotice extends BaseTimeEntity { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; diff --git a/src/main/java/sopt/univoice/domain/notice/repository/NoticeLikeRepository.java b/src/main/java/sopt/univoice/domain/notice/repository/NoticeLikeRepository.java index 56e9160..38d9f56 100644 --- a/src/main/java/sopt/univoice/domain/notice/repository/NoticeLikeRepository.java +++ b/src/main/java/sopt/univoice/domain/notice/repository/NoticeLikeRepository.java @@ -9,4 +9,6 @@ public interface NoticeLikeRepository extends JpaRepository { Optional findByNoticeAndMember(Notice notice, Member member); + + boolean existsByNoticeAndMember(Notice notice, Member member); } diff --git a/src/main/java/sopt/univoice/domain/notice/repository/SaveNoticeRepository.java b/src/main/java/sopt/univoice/domain/notice/repository/SaveNoticeRepository.java index df912fa..21f635b 100644 --- a/src/main/java/sopt/univoice/domain/notice/repository/SaveNoticeRepository.java +++ b/src/main/java/sopt/univoice/domain/notice/repository/SaveNoticeRepository.java @@ -12,6 +12,8 @@ public interface SaveNoticeRepository extends JpaRepository{ Optional findByNoticeAndMember(Notice notice, Member member); List findByMember(Member member); + + boolean existsByNoticeAndMember(Notice notice, Member member); } diff --git a/src/main/java/sopt/univoice/domain/notice/service/NoticeService.java b/src/main/java/sopt/univoice/domain/notice/service/NoticeService.java index de21ccf..a379c7e 100644 --- a/src/main/java/sopt/univoice/domain/notice/service/NoticeService.java +++ b/src/main/java/sopt/univoice/domain/notice/service/NoticeService.java @@ -13,12 +13,14 @@ import sopt.univoice.domain.user.entity.Member; import sopt.univoice.domain.affiliation.entity.Role; import sopt.univoice.infra.common.exception.UnauthorizedException; +import sopt.univoice.infra.common.exception.message.BusinessException; import sopt.univoice.infra.common.exception.message.ErrorMessage; import sopt.univoice.infra.external.OpenAiService; import sopt.univoice.infra.external.S3Service; import java.io.IOException; import java.util.ArrayList; +import java.util.Comparator; import java.util.List; import java.util.stream.Collectors; @@ -32,16 +34,16 @@ public class NoticeService { private final PrincipalHandler principalHandler; private final S3Service s3Service; private final OpenAiService openAiService; + private final NoticeViewRepository noticeViewRepository; private final NoticeLikeRepository noticeLikeRepository; private final SaveNoticeRepository saveNoticeRepository; - private final NoticeViewRepository noticeViewRepository; @Transactional public void createPost(NoticeCreateRequest noticeCreateRequest) { Long memberId = principalHandler.getUserIdFromPrincipal(); System.out.println("Authenticated Member ID: " + memberId); Member member = authRepository.findById(memberId) - .orElseThrow(() -> new RuntimeException("회원이 존재하지 않습니다.")); + .orElseThrow(() -> new RuntimeException("회원이 존재하지 않습니다.")); System.out.println("Member Role: " + member.getAffiliation().getRole()); String summarizedContent = null; @@ -61,27 +63,36 @@ public void createPost(NoticeCreateRequest noticeCreateRequest) { // Notice 엔티티 생성 및 저장 Notice notice = Notice.builder() - .title(noticeCreateRequest.getTitle()) - .content(noticeCreateRequest.getContent()) - .target(noticeCreateRequest.getTarget()) - .startTime(noticeCreateRequest.getStartTime()) - .endTime(noticeCreateRequest.getEndTime()) - .member(member) - .contentSummary(summarizedContent) - .category("공지사항") // category 값을 '공지사항'으로 설정 - .build(); + .title(noticeCreateRequest.getTitle()) + .content(noticeCreateRequest.getContent()) + .target(noticeCreateRequest.getTarget() != null ? noticeCreateRequest.getTarget() : "") + .startTime(noticeCreateRequest.getStartTime() != null ? noticeCreateRequest.getStartTime() : null) + .endTime(noticeCreateRequest.getEndTime() != null ? noticeCreateRequest.getEndTime() : null) + .member(member) + .contentSummary(summarizedContent) + .category("공지사항") // category 값을 '공지사항'으로 설정 + .build(); noticeRepository.save(notice); System.out.println("Notice saved successfully with ID: " + notice.getId()); // NoticeImage 엔티티 생성 및 저장 - for (MultipartFile file : noticeCreateRequest.getStudentCardImages()) { - String fileName = storeFile(file); // 파일 저장 로직 필요 - NoticeImage noticeImage = NoticeImage.builder() - .notice(notice) - .noticeImage(fileName) - .build(); - noticeImageRepository.save(noticeImage); - System.out.println("NoticeImage saved successfully with file name: " + fileName); + List files = noticeCreateRequest.getImageList(); + + if (files != null && !files.isEmpty()) { + for (MultipartFile file : files) { + try { + String fileUrl = storeFile(file); // 파일 저장 로직 필요 + NoticeImage noticeImage = NoticeImage.builder() + .notice(notice) + .noticeImage(fileUrl) + .build(); + noticeImageRepository.save(noticeImage); + System.out.println("NoticeImage saved successfully with file URL: " + fileUrl); + } catch (Exception e) { + System.err.println("Error saving NoticeImage: " + e.getMessage()); + e.printStackTrace(); + } + } } // NoticeView 엔티티 생성 및 저장 @@ -90,24 +101,24 @@ public void createPost(NoticeCreateRequest noticeCreateRequest) { for (Member universityMember : universityMembers) { NoticeView noticeView = NoticeView.builder() - .notice(notice) - .member(universityMember) - .readAt(false) - .build(); + .notice(notice) + .member(universityMember) + .readAt(false) + .build(); noticeViewRepository.save(noticeView); } - } private String storeFile(MultipartFile file) { try { - return s3Service.uploadImage("notice-images/", file); + String fileUrl = s3Service.uploadImage("notice-images/", file); + return fileUrl; } catch (IOException e) { + System.err.println("File upload failed: " + e.getMessage()); throw new RuntimeException("파일 업로드에 실패했습니다.", e); } } - @Transactional public void likeNotice(Long noticeId) { Long memberId = principalHandler.getUserIdFromPrincipal(); @@ -118,6 +129,12 @@ public void likeNotice(Long noticeId) { Notice notice = noticeRepository.findById(noticeId) .orElseThrow(() -> new RuntimeException("공지사항이 존재하지 않습니다.")); + // 이미 좋아요를 누른 것인지 확인 + boolean alreadyLiked = noticeLikeRepository.existsByNoticeAndMember(notice, member); + if (alreadyLiked) { + throw new BusinessException(ErrorMessage.ALREADY_LIKED); + } + // noticeLike를 1 증가시킵니다. notice.setNoticeLike(notice.getNoticeLike() + 1); noticeRepository.save(notice); @@ -161,6 +178,16 @@ public void saveNotice(Long noticeId) { Notice notice = noticeRepository.findById(noticeId) .orElseThrow(() -> new RuntimeException("공지사항이 존재하지 않습니다.")); + // 이미 저장한 공지인지 확인하는 부분 추가 + boolean alreadySaved = saveNoticeRepository.existsByNoticeAndMember(notice, member); + if (alreadySaved) { + throw new BusinessException(ErrorMessage.ALREADY_SAVED); + } + + // noticeSave를 1 증가시킵니다. + notice.setNoticeSave(notice.getNoticeSave() + 1); + noticeRepository.save(notice); + // SaveNotice 엔티티 생성 및 저장 SaveNotice saveNotice = SaveNotice.builder() .notice(notice) @@ -179,9 +206,13 @@ public void saveCancleNotice(Long noticeId) { Notice notice = noticeRepository.findById(noticeId) .orElseThrow(() -> new RuntimeException("공지사항이 존재하지 않습니다.")); + // noticeSave를 1 감소시킵니다. + notice.setNoticeSave(notice.getNoticeSave() - 1); + noticeRepository.save(notice); + // SaveNotice 엔티티 삭제 SaveNotice saveNotice = saveNoticeRepository.findByNoticeAndMember(notice, member) - .orElseThrow(() -> new RuntimeException("저장된 공지사항이 존재하지 않습니다.")); + .orElseThrow(() -> new RuntimeException("저장된 공지사항이 존재하지 않습니다.")); saveNoticeRepository.delete(saveNotice); } @@ -195,19 +226,21 @@ public List getSaveNoticeByUser() { List saveNotices = saveNoticeRepository.findByMember(member); return saveNotices.stream() - .map(saveNotice -> { - Notice notice = saveNotice.getNotice(); - return new NoticeSaveDTO( - notice.getId(), - notice.getTitle(), - notice.getViewCount(), - notice.getNoticeLike(), - notice.getCategory(), - notice.getStartTime(), - notice.getEndTime() - ); - }) - .collect(Collectors.toList()); + .map(saveNotice -> { + Notice notice = saveNotice.getNotice(); + String image = notice.getNoticeImages().isEmpty() ? null : notice.getNoticeImages().get(0).getNoticeImage(); + return new NoticeSaveDTO( + notice.getId(), + notice.getTitle(), + notice.getViewCount(), + notice.getNoticeLike(), + notice.getCategory(), + notice.getCreatedAt(), + image + ); + }) + .sorted(Comparator.comparing(NoticeSaveDTO::getCreatedAt)) + .collect(Collectors.toList()); } @Transactional @@ -463,17 +496,14 @@ public NoticeDetailResponseDTO getNoticeById(Long noticeId) { return new NoticeDetailResponseDTO( notice.getId(), + writeAffiliation, notice.getTitle(), - notice.getContent(), - notice.getNoticeLike(), - notice.getViewCount(), notice.getTarget(), notice.getStartTime(), notice.getEndTime(), - notice.getCategory(), - notice.getContentSummary(), - notice.getMember().getId(), - writeAffiliation, // 추가된 부분 + notice.getContent(), + notice.getCreatedAt(), + notice.getViewCount(), notice.getNoticeImages().stream().map(NoticeImage::getNoticeImage).collect(Collectors.toList()) ); } diff --git a/src/main/java/sopt/univoice/infra/common/exception/ValidationExceptionHandler.java b/src/main/java/sopt/univoice/infra/common/exception/ValidationExceptionHandler.java new file mode 100644 index 0000000..f8c1c2a --- /dev/null +++ b/src/main/java/sopt/univoice/infra/common/exception/ValidationExceptionHandler.java @@ -0,0 +1,24 @@ +package sopt.univoice.infra.common.exception; + +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +import java.util.HashMap; +import java.util.Map; + +@RestControllerAdvice +public class ValidationExceptionHandler { + + @ExceptionHandler(MethodArgumentNotValidException.class) + public ResponseEntity> handleValidationExceptions(MethodArgumentNotValidException ex) { + Map errors = new HashMap<>(); + ex.getBindingResult().getFieldErrors().forEach(error -> + errors.put(error.getField(), error.getDefaultMessage()) + ); + return new ResponseEntity<>(errors, HttpStatus.BAD_REQUEST); + } +} + diff --git a/src/main/java/sopt/univoice/infra/common/exception/message/ErrorMessage.java b/src/main/java/sopt/univoice/infra/common/exception/message/ErrorMessage.java index c3bea00..d9581d2 100644 --- a/src/main/java/sopt/univoice/infra/common/exception/message/ErrorMessage.java +++ b/src/main/java/sopt/univoice/infra/common/exception/message/ErrorMessage.java @@ -12,6 +12,8 @@ public enum ErrorMessage { EMAIL_DUPLICATE(HttpStatus.NO_CONTENT.value(), "이미 사용중인 이메일 입니다."), JWT_UNAUTHORIZED_EXCEPTION(HttpStatus.UNAUTHORIZED.value(), "사용자의 로그인 검증을 실패했습니다."), APPROVEADMIN_UNAUTHORIZED_EXCEPTION(HttpStatus.UNAUTHORIZED.value(), "승인된 관리자의 로그인 검증을 실패했습니다."), + ALREADY_SAVED(HttpStatus.CONFLICT.value(), "이미 저장한 공지입니다."), + ALREADY_LIKED(HttpStatus.CONFLICT.value(), "이미 좋아요한 공지입니다."), ; private final int status; private final String message; diff --git a/src/main/java/sopt/univoice/infra/external/S3Service.java b/src/main/java/sopt/univoice/infra/external/S3Service.java index 6852aea..4741d45 100644 --- a/src/main/java/sopt/univoice/infra/external/S3Service.java +++ b/src/main/java/sopt/univoice/infra/external/S3Service.java @@ -20,15 +20,17 @@ public class S3Service { private final String bucketName; private final AwsConfig awsConfig; + private final String region; private static final List IMAGE_EXTENSIONS = Arrays.asList("image/jpeg", "image/png", "image/jpg", "image/webp"); - - public S3Service(@Value("${aws-property.s3-bucket-name}") final String bucketName, AwsConfig awsConfig) { + public S3Service(@Value("${aws-property.s3-bucket-name}") final String bucketName, + AwsConfig awsConfig, + @Value("${aws-property.aws-region}") final String region) { this.bucketName = bucketName; this.awsConfig = awsConfig; + this.region = region; } - public String uploadImage(String directoryPath, MultipartFile image) throws IOException { final String key = directoryPath + generateImageFileName(); final S3Client s3Client = awsConfig.getS3Client(); @@ -37,33 +39,33 @@ public String uploadImage(String directoryPath, MultipartFile image) throws IOEx validateFileSize(image); PutObjectRequest request = PutObjectRequest.builder() - .bucket(bucketName) - .key(key) - .contentType(image.getContentType()) - .contentDisposition("inline") - .build(); + .bucket(bucketName) + .key(key) + .contentType(image.getContentType()) + .contentDisposition("inline") + .build(); RequestBody requestBody = RequestBody.fromBytes(image.getBytes()); s3Client.putObject(request, requestBody); - return key; + + // Return the URL of the uploaded image + return String.format("https://%s.s3.%s.amazonaws.com/%s", bucketName, region, key); } public void deleteImage(String key) throws IOException { final S3Client s3Client = awsConfig.getS3Client(); s3Client.deleteObject((DeleteObjectRequest.Builder builder) -> - builder.bucket(bucketName) - .key(key) - .build() + builder.bucket(bucketName) + .key(key) + .build() ); } - private String generateImageFileName() { return UUID.randomUUID() + ".jpg"; } - private void validateExtension(MultipartFile image) { String contentType = image.getContentType(); if (!IMAGE_EXTENSIONS.contains(contentType)) { @@ -78,6 +80,4 @@ private void validateFileSize(MultipartFile image) { throw new RuntimeException("이미지 사이즈는 5MB를 넘을 수 없습니다."); } } - -} - +} \ No newline at end of file