diff --git a/build.gradle b/build.gradle index 3241b91..6b6df67 100644 --- a/build.gradle +++ b/build.gradle @@ -68,6 +68,9 @@ dependencies { // Firebase implementation 'com.google.firebase:firebase-admin:9.2.0' + // AWS (v2 SDK BOM + S3) + implementation 'software.amazon.awssdk:s3:2.25.69' + // Test testImplementation 'org.springframework.boot:spring-boot-starter-test' testImplementation 'io.projectreactor:reactor-test' diff --git a/src/main/java/com/project/dorumdorum/domain/image/domain/entity/Image.java b/src/main/java/com/project/dorumdorum/domain/image/domain/entity/Image.java new file mode 100644 index 0000000..e570c80 --- /dev/null +++ b/src/main/java/com/project/dorumdorum/domain/image/domain/entity/Image.java @@ -0,0 +1,32 @@ +package com.project.dorumdorum.domain.image.domain.entity; + +import com.project.dorumdorum.global.common.BaseEntity; +import io.hypersistence.utils.hibernate.id.Tsid; +import jakarta.persistence.*; +import lombok.*; + +@Entity +@Table(name = "image") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PROTECTED) +@Builder +public class Image extends BaseEntity { + + @Id + @Tsid + private Long imageNo; + + @Column(nullable = false) + private Long noticeNo; + + @Column(nullable = false) + private String s3Key; + + @Column(nullable = false) + private String fileName; + + @Column(nullable = false) + private Long fileSize; +} + diff --git a/src/main/java/com/project/dorumdorum/domain/image/domain/repository/ImageRepository.java b/src/main/java/com/project/dorumdorum/domain/image/domain/repository/ImageRepository.java new file mode 100644 index 0000000..54a95e2 --- /dev/null +++ b/src/main/java/com/project/dorumdorum/domain/image/domain/repository/ImageRepository.java @@ -0,0 +1,14 @@ +package com.project.dorumdorum.domain.image.domain.repository; + +import com.project.dorumdorum.domain.image.domain.entity.Image; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface ImageRepository extends JpaRepository { + + Optional findByNoticeNo(Long noticeNo); + + long deleteByNoticeNo(Long noticeNo); +} + diff --git a/src/main/java/com/project/dorumdorum/domain/image/domain/service/ImageService.java b/src/main/java/com/project/dorumdorum/domain/image/domain/service/ImageService.java new file mode 100644 index 0000000..fbce78a --- /dev/null +++ b/src/main/java/com/project/dorumdorum/domain/image/domain/service/ImageService.java @@ -0,0 +1,44 @@ +package com.project.dorumdorum.domain.image.domain.service; + +import com.project.dorumdorum.domain.image.domain.entity.Image; +import com.project.dorumdorum.domain.image.domain.repository.ImageRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Optional; + +@Service +@RequiredArgsConstructor +public class ImageService { + + private final ImageRepository imageRepository; + + @Transactional + public Image saveNoticeImage(Long noticeNo, String s3Key, String fileName, Long fileSize) { + Image image = Image.builder() + .noticeNo(noticeNo) + .s3Key(s3Key) + .fileName(fileName) + .fileSize(fileSize) + .build(); + return imageRepository.save(image); + } + + @Transactional(readOnly = true) + public Optional findByNoticeNo(Long noticeNo) { + return imageRepository.findByNoticeNo(noticeNo); + } + + @Transactional + public void softDelete(Image image) { + image.delete(); + imageRepository.save(image); + } + + @Transactional + public void deleteByNoticeNo(Long noticeNo) { + imageRepository.deleteByNoticeNo(noticeNo); + } +} + diff --git a/src/main/java/com/project/dorumdorum/domain/notice/application/dto/request/UpdateCommentRequest.java b/src/main/java/com/project/dorumdorum/domain/notice/application/dto/request/UpdateCommentRequest.java new file mode 100644 index 0000000..12f84cc --- /dev/null +++ b/src/main/java/com/project/dorumdorum/domain/notice/application/dto/request/UpdateCommentRequest.java @@ -0,0 +1,8 @@ +package com.project.dorumdorum.domain.notice.application.dto.request; + +public record UpdateCommentRequest( + Long commentNo, + String content +) { +} + diff --git a/src/main/java/com/project/dorumdorum/domain/notice/application/dto/request/UpdateNoticeRequest.java b/src/main/java/com/project/dorumdorum/domain/notice/application/dto/request/UpdateNoticeRequest.java new file mode 100644 index 0000000..fa108cc --- /dev/null +++ b/src/main/java/com/project/dorumdorum/domain/notice/application/dto/request/UpdateNoticeRequest.java @@ -0,0 +1,16 @@ +package com.project.dorumdorum.domain.notice.application.dto.request; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import org.springframework.lang.Nullable; + +public record UpdateNoticeRequest( + @NotNull Long roomNo, + @NotNull Long noticeNo, + @NotBlank String title, + @NotBlank String content, + @Nullable String imageFileName, + @Nullable Long imageFileSize, + @Nullable Boolean deleteImage +) { +} diff --git a/src/main/java/com/project/dorumdorum/domain/notice/application/dto/request/WriteCommentRequest.java b/src/main/java/com/project/dorumdorum/domain/notice/application/dto/request/WriteCommentRequest.java new file mode 100644 index 0000000..939cb68 --- /dev/null +++ b/src/main/java/com/project/dorumdorum/domain/notice/application/dto/request/WriteCommentRequest.java @@ -0,0 +1,7 @@ +package com.project.dorumdorum.domain.notice.application.dto.request; + +public record WriteCommentRequest( + Long noticeNo, + String content +) { +} diff --git a/src/main/java/com/project/dorumdorum/domain/notice/application/dto/request/WriteNoticeRequest.java b/src/main/java/com/project/dorumdorum/domain/notice/application/dto/request/WriteNoticeRequest.java new file mode 100644 index 0000000..aab4c3e --- /dev/null +++ b/src/main/java/com/project/dorumdorum/domain/notice/application/dto/request/WriteNoticeRequest.java @@ -0,0 +1,15 @@ +package com.project.dorumdorum.domain.notice.application.dto.request; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import org.springframework.lang.Nullable; + +public record WriteNoticeRequest( + @NotNull Long roomNo, + @NotBlank String title, + @NotBlank String content, + @Nullable String imageFileName, + @Nullable Long imageFileSize +) { + +} diff --git a/src/main/java/com/project/dorumdorum/domain/notice/application/dto/response/CommentResponse.java b/src/main/java/com/project/dorumdorum/domain/notice/application/dto/response/CommentResponse.java new file mode 100644 index 0000000..cdbb349 --- /dev/null +++ b/src/main/java/com/project/dorumdorum/domain/notice/application/dto/response/CommentResponse.java @@ -0,0 +1,23 @@ +package com.project.dorumdorum.domain.notice.application.dto.response; + +import com.project.dorumdorum.domain.notice.domain.entity.Comment; +import lombok.Builder; + +import java.time.LocalDateTime; + +@Builder +public record CommentResponse( + Long commentNo, + Long userNo, + LocalDateTime updated_at, + String content +) { + public static CommentResponse create(Comment comment) { + return CommentResponse.builder() + .commentNo(comment.getCommentNo()) + .userNo(comment.getUserNo()) + .updated_at(comment.getUpdatedAt()) + .content(comment.getContent()) + .build(); + } +} diff --git a/src/main/java/com/project/dorumdorum/domain/notice/application/dto/response/NoticeResponse.java b/src/main/java/com/project/dorumdorum/domain/notice/application/dto/response/NoticeResponse.java new file mode 100644 index 0000000..b0a344f --- /dev/null +++ b/src/main/java/com/project/dorumdorum/domain/notice/application/dto/response/NoticeResponse.java @@ -0,0 +1,53 @@ +package com.project.dorumdorum.domain.notice.application.dto.response; + +import com.project.dorumdorum.domain.notice.domain.entity.Notice; + +import lombok.Builder; + +import java.time.LocalDateTime; +import java.util.Collections; +import java.util.List; + +@Builder +public record NoticeResponse( + Long noticeNo, + Long userNo, + LocalDateTime createdAt, + LocalDateTime updatedAt, + String title, + String content, + String imageUploadUrl, + String imageDownloadUrl, + String imageFileName, + Long imageFileSize, + List comments +) { + public static NoticeResponse create(Notice notice) { + return create(notice, Collections.emptyList(), null, null, null, null); + } + + public static NoticeResponse create(Notice notice, List comments) { + return create(notice, comments, null, null, null, null); + } + + public static NoticeResponse create(Notice notice, + List comments, + String imageUploadUrl, + String imageDownloadUrl, + String imageFileName, + Long imageFileSize) { + return NoticeResponse.builder() + .noticeNo(notice.getNoticeNo()) + .userNo(notice.getUserNo()) + .createdAt(notice.getCreatedAt()) + .updatedAt(notice.getUpdatedAt()) + .title(notice.getTitle()) + .content(notice.getContent()) + .imageUploadUrl(imageUploadUrl) + .imageDownloadUrl(imageDownloadUrl) + .imageFileName(imageFileName) + .imageFileSize(imageFileSize) + .comments(comments == null ? Collections.emptyList() : comments) + .build(); + } +} diff --git a/src/main/java/com/project/dorumdorum/domain/notice/application/dto/response/NoticesResponse.java b/src/main/java/com/project/dorumdorum/domain/notice/application/dto/response/NoticesResponse.java new file mode 100644 index 0000000..2e2e004 --- /dev/null +++ b/src/main/java/com/project/dorumdorum/domain/notice/application/dto/response/NoticesResponse.java @@ -0,0 +1,23 @@ +package com.project.dorumdorum.domain.notice.application.dto.response; + +import com.project.dorumdorum.domain.notice.domain.entity.Notice; +import lombok.Builder; + +import java.time.LocalDateTime; + +@Builder +public record NoticesResponse( + Long noticeNo, + Long userNo, + LocalDateTime updatedAt, + String title +) { + public static NoticesResponse create(Notice notice) { + return NoticesResponse.builder() + .noticeNo(notice.getNoticeNo()) + .userNo(notice.getUserNo()) + .updatedAt(notice.getUpdatedAt()) + .title(notice.getTitle()) + .build(); + } +} diff --git a/src/main/java/com/project/dorumdorum/domain/notice/application/usecase/DeleteCommentUseCase.java b/src/main/java/com/project/dorumdorum/domain/notice/application/usecase/DeleteCommentUseCase.java new file mode 100644 index 0000000..85cc3aa --- /dev/null +++ b/src/main/java/com/project/dorumdorum/domain/notice/application/usecase/DeleteCommentUseCase.java @@ -0,0 +1,31 @@ +package com.project.dorumdorum.domain.notice.application.usecase; + +import com.project.dorumdorum.domain.notice.domain.entity.Comment; +import com.project.dorumdorum.domain.notice.service.CommentService; +import com.project.dorumdorum.domain.user.domain.service.UserService; +import com.project.dorumdorum.global.exception.RestApiException; +import com.project.dorumdorum.global.exception.code.status.GlobalErrorStatus; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class DeleteCommentUseCase { + + private final UserService userService; + private final CommentService commentService; + + @Transactional + public void execute(Long userNo, Long commentNo) { + userService.validateExistsById(userNo); + + Comment comment = commentService.findActiveById(commentNo); + + if (!comment.isWriter(userNo)) + throw new RestApiException(GlobalErrorStatus.NO_PERMISSION_ON_NOTICE); + + commentService.softDelete(comment); + } +} + diff --git a/src/main/java/com/project/dorumdorum/domain/notice/application/usecase/DeleteNoticeUseCase.java b/src/main/java/com/project/dorumdorum/domain/notice/application/usecase/DeleteNoticeUseCase.java new file mode 100644 index 0000000..bc16071 --- /dev/null +++ b/src/main/java/com/project/dorumdorum/domain/notice/application/usecase/DeleteNoticeUseCase.java @@ -0,0 +1,43 @@ +package com.project.dorumdorum.domain.notice.application.usecase; + +import com.project.dorumdorum.domain.image.domain.entity.Image; +import com.project.dorumdorum.domain.image.domain.service.ImageService; +import com.project.dorumdorum.domain.notice.domain.entity.Notice; +import com.project.dorumdorum.domain.notice.infra.S3PresignedUrlService; +import com.project.dorumdorum.domain.notice.service.NoticeService; +import com.project.dorumdorum.domain.user.domain.service.UserService; +import com.project.dorumdorum.global.exception.RestApiException; +import com.project.dorumdorum.global.exception.code.status.GlobalErrorStatus; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class DeleteNoticeUseCase { + + private final UserService userService; + private final NoticeService noticeService; + private final ImageService imageService; + private final S3PresignedUrlService s3PresignedUrlService; + + @Transactional + public void execute(Long userNo, Long noticeNo) { + userService.validateExistsById(userNo); + + Notice notice = noticeService.findById(noticeNo); + + if (notice.isDeleted()) + throw new RestApiException(GlobalErrorStatus.NOTICE_ALREADY_DELETED); + if (!notice.isWriter(userNo)) + throw new RestApiException(GlobalErrorStatus.NO_PERMISSION_ON_NOTICE); + + Image image = imageService.findByNoticeNo(noticeNo).orElse(null); + if (image != null) { + s3PresignedUrlService.deleteObject(image.getS3Key()); // 버킷에는 하드 삭제 + imageService.softDelete(image); // image는 소프트 삭제 + } + + noticeService.softDelete(notice); // notice 소프트 삭제 + } +} diff --git a/src/main/java/com/project/dorumdorum/domain/notice/application/usecase/LoadNoticesUseCase.java b/src/main/java/com/project/dorumdorum/domain/notice/application/usecase/LoadNoticesUseCase.java new file mode 100644 index 0000000..be55fca --- /dev/null +++ b/src/main/java/com/project/dorumdorum/domain/notice/application/usecase/LoadNoticesUseCase.java @@ -0,0 +1,66 @@ +package com.project.dorumdorum.domain.notice.application.usecase; + +import com.project.dorumdorum.domain.image.domain.entity.Image; +import com.project.dorumdorum.domain.image.domain.service.ImageService; +import com.project.dorumdorum.domain.notice.application.dto.response.NoticeResponse; +import com.project.dorumdorum.domain.notice.application.dto.response.NoticesResponse; +import com.project.dorumdorum.domain.notice.application.dto.response.CommentResponse; +import com.project.dorumdorum.domain.notice.domain.entity.Notice; +import com.project.dorumdorum.domain.notice.infra.S3PresignedUrlService; +import com.project.dorumdorum.domain.notice.service.NoticeService; +import com.project.dorumdorum.domain.notice.service.CommentService; +import com.project.dorumdorum.domain.room.domain.entity.Room; +import com.project.dorumdorum.domain.room.domain.service.RoomService; +import com.project.dorumdorum.domain.room.domain.service.RoommateService; +import com.project.dorumdorum.domain.user.domain.service.UserService; +import com.project.dorumdorum.global.exception.RestApiException; +import com.project.dorumdorum.global.exception.code.status.GlobalErrorStatus; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +import java.util.List; + +@Service +@RequiredArgsConstructor +public class LoadNoticesUseCase { + + private final UserService userService; + private final RoommateService roommateService; + private final RoomService roomService; + private final NoticeService noticeService; + private final ImageService imageService; + private final S3PresignedUrlService s3PresignedUrlService; + private final CommentService commentService; + + public List loadNotices(Long userNo, Long roomNo) { + userService.validateExistsById(userNo); + + Room room = roomService.findById(roomNo); + if(!roommateService.isUserInRoom(userNo, room)) + throw new RestApiException(GlobalErrorStatus.USER_NOT_IN_ROOM); + + return noticeService.loadNoticeList(room.getRoomNo()); + } + + public NoticeResponse loadNotice(Long userNo, Long noticeNo) { + userService.validateExistsById(userNo); + + Notice notice = noticeService.findById(noticeNo); + Image image = imageService.findByNoticeNo(noticeNo).orElse(null); + List comments = commentService.findByNoticeNo(noticeNo).stream() + .map(CommentResponse::create) + .toList(); + + String downloadUrl = null; + String fileName = null; + Long fileSize = null; + + if (image != null) { + downloadUrl = s3PresignedUrlService.generateDownloadPresignedUrl(image.getS3Key()); + fileName = image.getFileName(); + fileSize = image.getFileSize(); + } + + return NoticeResponse.create(notice, comments, null, downloadUrl, fileName, fileSize); + } +} diff --git a/src/main/java/com/project/dorumdorum/domain/notice/application/usecase/UpdateCommentUseCase.java b/src/main/java/com/project/dorumdorum/domain/notice/application/usecase/UpdateCommentUseCase.java new file mode 100644 index 0000000..3d47b3e --- /dev/null +++ b/src/main/java/com/project/dorumdorum/domain/notice/application/usecase/UpdateCommentUseCase.java @@ -0,0 +1,37 @@ +package com.project.dorumdorum.domain.notice.application.usecase; + +import com.project.dorumdorum.domain.notice.application.dto.request.UpdateCommentRequest; +import com.project.dorumdorum.domain.notice.application.dto.response.CommentResponse; +import com.project.dorumdorum.domain.notice.domain.entity.Comment; +import com.project.dorumdorum.domain.notice.service.CommentService; +import com.project.dorumdorum.domain.user.domain.service.UserService; +import com.project.dorumdorum.global.exception.RestApiException; +import com.project.dorumdorum.global.exception.code.status.GlobalErrorStatus; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class UpdateCommentUseCase { + + private final UserService userService; + private final CommentService commentService; + + @Transactional + public CommentResponse execute(Long userNo, UpdateCommentRequest request) { + userService.validateExistsById(userNo); + + Comment comment = commentService.findActiveById(request.commentNo()); + + if (!comment.isWriter(userNo)) + throw new RestApiException(GlobalErrorStatus.NO_PERMISSION_ON_NOTICE); + if (request.content() == null || request.content().isEmpty()) + throw new RestApiException(GlobalErrorStatus.CONTENT_IS_EMPTY); + + Comment updated = commentService.updateComment(comment, request.content()); + + return CommentResponse.create(updated); + } +} + diff --git a/src/main/java/com/project/dorumdorum/domain/notice/application/usecase/UpdateNoticeUseCase.java b/src/main/java/com/project/dorumdorum/domain/notice/application/usecase/UpdateNoticeUseCase.java new file mode 100644 index 0000000..9032040 --- /dev/null +++ b/src/main/java/com/project/dorumdorum/domain/notice/application/usecase/UpdateNoticeUseCase.java @@ -0,0 +1,92 @@ +package com.project.dorumdorum.domain.notice.application.usecase; + +import com.project.dorumdorum.domain.image.domain.entity.Image; +import com.project.dorumdorum.domain.image.domain.service.ImageService; +import com.project.dorumdorum.domain.notice.application.dto.request.UpdateNoticeRequest; +import com.project.dorumdorum.domain.notice.application.dto.response.NoticeResponse; +import com.project.dorumdorum.domain.notice.domain.entity.Notice; +import com.project.dorumdorum.domain.notice.infra.S3PresignedUrlService; +import com.project.dorumdorum.domain.notice.service.NoticeService; +import com.project.dorumdorum.domain.room.domain.entity.Room; +import com.project.dorumdorum.domain.room.domain.service.RoomService; +import com.project.dorumdorum.domain.user.domain.service.UserService; +import com.project.dorumdorum.global.exception.RestApiException; +import com.project.dorumdorum.global.exception.code.status.GlobalErrorStatus; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Collections; + +@Service +@RequiredArgsConstructor +public class UpdateNoticeUseCase { + + private final UserService userService; + private final RoomService roomService; + private final NoticeService noticeService; + private final ImageService imageService; + private final S3PresignedUrlService s3PresignedUrlService; + + @Transactional + public NoticeResponse execute(Long userNo, UpdateNoticeRequest request) { + userService.validateExistsById(userNo); + + Room room = roomService.findById(request.roomNo()); + + // 방장이 아닌 구성원도 작성가능 할 경우 삭제 + if(!room.isHost(userNo)) + throw new RestApiException(GlobalErrorStatus.NO_PERMISSION_ON_NOTICE); + + Notice notice = noticeService.findById(request.noticeNo()); + + if(!notice.isWriter(userNo)) + throw new RestApiException(GlobalErrorStatus.NO_PERMISSION_ON_NOTICE); + + Image existingImage = imageService.findByNoticeNo(notice.getNoticeNo()).orElse(null); + + boolean deleteImage = Boolean.TRUE.equals(request.deleteImage()); + boolean hasNewImage = hasImageInfo(request); + + if (deleteImage || hasNewImage) { + if (existingImage != null) { + s3PresignedUrlService.deleteObject(existingImage.getS3Key()); + imageService.deleteByNoticeNo(notice.getNoticeNo()); + existingImage = null; + } + } + + String imageUploadUrl = null; + String imageDownloadUrl = null; + String imageFileName = null; + Long imageFileSize = null; + + if (hasNewImage) { + if (request.imageFileSize() == null) + throw new RestApiException(GlobalErrorStatus._BAD_REQUEST); + + imageFileName = request.imageFileName(); + imageFileSize = request.imageFileSize(); + + S3PresignedUrlService.UploadUrlInfo uploadInfo = + s3PresignedUrlService.generateUploadPresignedUrl(request.roomNo(), imageFileName); + + imageService.saveNoticeImage(notice.getNoticeNo(), uploadInfo.s3Key(), imageFileName, imageFileSize); + + imageUploadUrl = uploadInfo.uploadUrl(); + imageDownloadUrl = s3PresignedUrlService.generateDownloadPresignedUrl(uploadInfo.s3Key()); + } else if (!deleteImage && existingImage != null) { + imageFileName = existingImage.getFileName(); + imageFileSize = existingImage.getFileSize(); + imageDownloadUrl = s3PresignedUrlService.generateDownloadPresignedUrl(existingImage.getS3Key()); + } + + Notice updatedNotice = noticeService.updateNotice(notice, request); + + return NoticeResponse.create(updatedNotice, Collections.emptyList(), imageUploadUrl, imageDownloadUrl, imageFileName, imageFileSize); + } + + private boolean hasImageInfo(UpdateNoticeRequest request) { + return request.imageFileName() != null && !request.imageFileName().isBlank(); + } +} diff --git a/src/main/java/com/project/dorumdorum/domain/notice/application/usecase/WriteCommentUseCase.java b/src/main/java/com/project/dorumdorum/domain/notice/application/usecase/WriteCommentUseCase.java new file mode 100644 index 0000000..b7f7264 --- /dev/null +++ b/src/main/java/com/project/dorumdorum/domain/notice/application/usecase/WriteCommentUseCase.java @@ -0,0 +1,35 @@ +package com.project.dorumdorum.domain.notice.application.usecase; + + +import com.project.dorumdorum.domain.notice.application.dto.request.WriteCommentRequest; +import com.project.dorumdorum.domain.notice.application.dto.response.CommentResponse; +import com.project.dorumdorum.domain.notice.domain.entity.Comment; +import com.project.dorumdorum.domain.notice.domain.entity.Notice; +import com.project.dorumdorum.domain.notice.service.CommentService; +import com.project.dorumdorum.domain.notice.service.NoticeService; +import com.project.dorumdorum.domain.user.domain.service.UserService; +import com.project.dorumdorum.global.exception.RestApiException; +import com.project.dorumdorum.global.exception.code.status.GlobalErrorStatus; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class WriteCommentUseCase { + + private final UserService userService; + private final NoticeService noticeService; + private final CommentService commentService; + + public CommentResponse execute(Long userNo, WriteCommentRequest request) { + userService.validateExistsById(userNo); + + Notice notice = noticeService.findById(request.noticeNo()); + + if(request.content().isEmpty()) + throw new RestApiException(GlobalErrorStatus.CONTENT_IS_EMPTY); + Comment comment = commentService.saveComment(userNo, notice.getNoticeNo(), request.content()); + + return CommentResponse.create(comment); + } +} diff --git a/src/main/java/com/project/dorumdorum/domain/notice/application/usecase/WriteNoticeUseCase.java b/src/main/java/com/project/dorumdorum/domain/notice/application/usecase/WriteNoticeUseCase.java new file mode 100644 index 0000000..b0a5465 --- /dev/null +++ b/src/main/java/com/project/dorumdorum/domain/notice/application/usecase/WriteNoticeUseCase.java @@ -0,0 +1,73 @@ +package com.project.dorumdorum.domain.notice.application.usecase; + +import com.project.dorumdorum.domain.image.domain.service.ImageService; +import com.project.dorumdorum.domain.notice.application.dto.request.WriteNoticeRequest; +import com.project.dorumdorum.domain.notice.application.dto.response.NoticeResponse; +import com.project.dorumdorum.domain.notice.domain.entity.Notice; +import com.project.dorumdorum.domain.notice.infra.S3PresignedUrlService; +import com.project.dorumdorum.domain.notice.service.NoticeService; +import com.project.dorumdorum.domain.room.domain.entity.Room; +import com.project.dorumdorum.domain.room.domain.service.RoomService; +import com.project.dorumdorum.domain.user.domain.service.UserService; +import com.project.dorumdorum.global.exception.RestApiException; +import com.project.dorumdorum.global.exception.code.status.GlobalErrorStatus; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Collections; + +@Service +@RequiredArgsConstructor +public class WriteNoticeUseCase { + + private final UserService userService; + private final NoticeService noticeService; + private final RoomService roomService; + private final ImageService imageService; + private final S3PresignedUrlService s3PresignedUrlService; + + @Transactional + public NoticeResponse execute(Long userNo, WriteNoticeRequest request) { + userService.validateExistsById(userNo); + + if(request.title().isEmpty()) + throw new RestApiException(GlobalErrorStatus.TITLE_IS_EMPTY); + if(request.content().isEmpty()) + throw new RestApiException(GlobalErrorStatus.CONTENT_IS_EMPTY); + + Room room = roomService.findById(request.roomNo()); + if(!room.isHost(userNo)) + throw new RestApiException(GlobalErrorStatus.NO_PERMISSION_ON_NOTICE); + + Notice notice = noticeService.writeNotice(userNo, room.getRoomNo(), request); + + String imageUploadUrl = null; + String imageDownloadUrl = null; + String imageFileName = null; + Long imageFileSize = null; + + if (hasImageInfo(request)) { + if (request.imageFileSize() == null) + throw new RestApiException(GlobalErrorStatus._BAD_REQUEST); + + imageFileName = request.imageFileName(); + imageFileSize = request.imageFileSize(); + + S3PresignedUrlService.UploadUrlInfo uploadInfo = + s3PresignedUrlService.generateUploadPresignedUrl(room.getRoomNo(), imageFileName); + + imageService.saveNoticeImage(notice.getNoticeNo(), uploadInfo.s3Key(), imageFileName, imageFileSize); + + imageUploadUrl = uploadInfo.uploadUrl(); + imageDownloadUrl = s3PresignedUrlService.generateDownloadPresignedUrl(uploadInfo.s3Key()); + } + + return NoticeResponse.create(notice, Collections.emptyList(), imageUploadUrl, imageDownloadUrl, imageFileName, imageFileSize); + + } + + private boolean hasImageInfo(WriteNoticeRequest request) { + return request.imageFileName() != null && !request.imageFileName().isBlank(); + } +} diff --git a/src/main/java/com/project/dorumdorum/domain/notice/domain/entity/Comment.java b/src/main/java/com/project/dorumdorum/domain/notice/domain/entity/Comment.java new file mode 100644 index 0000000..a054695 --- /dev/null +++ b/src/main/java/com/project/dorumdorum/domain/notice/domain/entity/Comment.java @@ -0,0 +1,36 @@ +package com.project.dorumdorum.domain.notice.domain.entity; + +import com.project.dorumdorum.global.common.BaseEntity; +import io.hypersistence.utils.hibernate.id.Tsid; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import lombok.*; + +@Entity +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PROTECTED) +@Getter +@Builder +public class Comment extends BaseEntity { + + @Id @Tsid + private Long commentNo; + + @Column(nullable = false) + private Long userNo; + + @Column(nullable = false) + private Long noticeNo; + + @Column(nullable = false) + private String content; + + public boolean isWriter(Long userNo) { + return this.userNo.equals(userNo); + } + + public void updateContent(String content) { + this.content = content; + } +} diff --git a/src/main/java/com/project/dorumdorum/domain/notice/domain/entity/Notice.java b/src/main/java/com/project/dorumdorum/domain/notice/domain/entity/Notice.java new file mode 100644 index 0000000..586831d --- /dev/null +++ b/src/main/java/com/project/dorumdorum/domain/notice/domain/entity/Notice.java @@ -0,0 +1,39 @@ +package com.project.dorumdorum.domain.notice.domain.entity; + +import com.project.dorumdorum.global.common.BaseEntity; + +import io.hypersistence.utils.hibernate.id.Tsid; +import jakarta.persistence.*; +import jakarta.validation.constraints.NotBlank; +import lombok.*; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PROTECTED) +@Builder +public class Notice extends BaseEntity { + @Id @Tsid + private Long noticeNo; + + @Column(nullable = false) + private Long roomNo; + + @Column(nullable = false) + private Long userNo; + + @Column(nullable = false) + private String title; + + @Column(nullable = false) + private String content; + + public void update(@NotBlank String title, @NotBlank String content) { + this.title = title; + this.content = content; + } + + public boolean isWriter(Long userNo) { + return this.userNo.equals(userNo); + } +} diff --git a/src/main/java/com/project/dorumdorum/domain/notice/domain/repository/CommentRepository.java b/src/main/java/com/project/dorumdorum/domain/notice/domain/repository/CommentRepository.java new file mode 100644 index 0000000..5f76550 --- /dev/null +++ b/src/main/java/com/project/dorumdorum/domain/notice/domain/repository/CommentRepository.java @@ -0,0 +1,10 @@ +package com.project.dorumdorum.domain.notice.domain.repository; + +import com.project.dorumdorum.domain.notice.domain.entity.Comment; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +public interface CommentRepository extends JpaRepository { + List findByNoticeNoAndDeletedAtIsNull(Long noticeNo); +} diff --git a/src/main/java/com/project/dorumdorum/domain/notice/domain/repository/NoticeRepository.java b/src/main/java/com/project/dorumdorum/domain/notice/domain/repository/NoticeRepository.java new file mode 100644 index 0000000..111d5f5 --- /dev/null +++ b/src/main/java/com/project/dorumdorum/domain/notice/domain/repository/NoticeRepository.java @@ -0,0 +1,11 @@ +package com.project.dorumdorum.domain.notice.domain.repository; + +import com.project.dorumdorum.domain.notice.domain.entity.Notice; + +import org.springframework.data.jpa.repository.JpaRepository; +import java.util.List; + +public interface NoticeRepository extends JpaRepository { + + List findByRoomNo(Long roomNo); +} diff --git a/src/main/java/com/project/dorumdorum/domain/notice/infra/S3PresignedUrlService.java b/src/main/java/com/project/dorumdorum/domain/notice/infra/S3PresignedUrlService.java new file mode 100644 index 0000000..28de4a8 --- /dev/null +++ b/src/main/java/com/project/dorumdorum/domain/notice/infra/S3PresignedUrlService.java @@ -0,0 +1,118 @@ +package com.project.dorumdorum.domain.notice.infra; + +import com.project.dorumdorum.global.properties.S3Properties; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.model.DeleteObjectRequest; +import software.amazon.awssdk.services.s3.model.GetObjectRequest; +import software.amazon.awssdk.services.s3.model.PutObjectRequest; +import software.amazon.awssdk.services.s3.presigner.S3Presigner; +import software.amazon.awssdk.services.s3.presigner.model.GetObjectPresignRequest; +import software.amazon.awssdk.services.s3.presigner.model.PresignedGetObjectRequest; +import software.amazon.awssdk.services.s3.presigner.model.PresignedPutObjectRequest; +import software.amazon.awssdk.services.s3.presigner.model.PutObjectPresignRequest; + +import java.time.Duration; +import java.util.UUID; + +@Slf4j +@Service +@RequiredArgsConstructor +public class S3PresignedUrlService { + + private final S3Presigner s3Presigner; + private final S3Client s3Client; + private final S3Properties s3Properties; + + // Presigned URL 유효 시간 + private static final Duration UPLOAD_URL_EXPIRATION = Duration.ofMinutes(5); + private static final Duration DOWNLOAD_URL_EXPIRATION = Duration.ofHours(1); + + // Notice 이미지 저장 경로 prefix + private static final String NOTICE_IMAGE_PREFIX = "notice/images/"; + + public UploadUrlInfo generateUploadPresignedUrl(Long roomNo, String fileName) { + String key = generateNoticeImageKey(roomNo, fileName); + + PutObjectRequest putObjectRequest = PutObjectRequest.builder() + .bucket(s3Properties.getBucket()) + .key(key) + .build(); + + PutObjectPresignRequest presignRequest = PutObjectPresignRequest.builder() + .signatureDuration(UPLOAD_URL_EXPIRATION) + .putObjectRequest(putObjectRequest) + .build(); + + PresignedPutObjectRequest presignedRequest = s3Presigner.presignPutObject(presignRequest); + String url = presignedRequest.url().toString(); + + log.info("Generated upload presigned URL for roomNo: {}, key: {}", roomNo, key); + + return new UploadUrlInfo(url, key); + } + + public String generateDownloadPresignedUrl(String key) { + GetObjectRequest getObjectRequest = GetObjectRequest.builder() + .bucket(s3Properties.getBucket()) + .key(key) + .build(); + + GetObjectPresignRequest presignRequest = GetObjectPresignRequest.builder() + .signatureDuration(DOWNLOAD_URL_EXPIRATION) + .getObjectRequest(getObjectRequest) + .build(); + + PresignedGetObjectRequest presignedRequest = s3Presigner.presignGetObject(presignRequest); + String url = presignedRequest.url().toString(); + + log.info("Generated download presigned URL for key: {}", key); + + return url; + } + + + public void deleteObject(String key) { + if (key == null || key.isEmpty()) { + log.warn("S3 key is null or empty, skipping deletion"); + return; + } + + try { + DeleteObjectRequest deleteObjectRequest = DeleteObjectRequest.builder() + .bucket(s3Properties.getBucket()) + .key(key) + .build(); + + s3Client.deleteObject(deleteObjectRequest); + log.info("Successfully deleted S3 object with key: {}", key); + } catch (Exception e) { + log.error("Failed to delete S3 object with key: {}", key, e); + throw new RuntimeException("S3 객체 삭제에 실패했습니다.", e); + } + } + + private String generateNoticeImageKey(Long roomNo, String fileName) { + String uuid = UUID.randomUUID().toString(); + String sanitizedFileName = sanitizeFileName(fileName); + return String.format("%s%d/%s_%s", NOTICE_IMAGE_PREFIX, roomNo, uuid, sanitizedFileName); + } + + private String sanitizeFileName(String fileName) { + if (fileName == null || fileName.isEmpty()) { + return "image.jpg"; + } + // 특수문자를 언더스코어로 변경, 공백 제거 + return fileName.replaceAll("[^a-zA-Z0-9.\\-_]", "_") + .replaceAll("\\s+", "") + .toLowerCase(); + } + + public record UploadUrlInfo( + String uploadUrl, + String s3Key + ) {} +} + diff --git a/src/main/java/com/project/dorumdorum/domain/notice/service/CommentService.java b/src/main/java/com/project/dorumdorum/domain/notice/service/CommentService.java new file mode 100644 index 0000000..d7e4a2f --- /dev/null +++ b/src/main/java/com/project/dorumdorum/domain/notice/service/CommentService.java @@ -0,0 +1,50 @@ +package com.project.dorumdorum.domain.notice.service; + +import com.project.dorumdorum.domain.notice.domain.entity.Comment; +import com.project.dorumdorum.domain.notice.domain.repository.CommentRepository; +import com.project.dorumdorum.global.exception.RestApiException; +import com.project.dorumdorum.global.exception.code.status.GlobalErrorStatus; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +import java.util.List; + +@Service +@RequiredArgsConstructor +public class CommentService { + + private final CommentRepository commentRepository; + + public Comment saveComment(Long userNo, Long noticeNo, String content) { + Comment entity = Comment.builder() + .userNo(userNo) + .noticeNo(noticeNo) + .content(content) + .build(); + return commentRepository.save(entity); + } + + public List findByNoticeNo(Long noticeNo) { + return commentRepository.findByNoticeNoAndDeletedAtIsNull(noticeNo); + } + + public Comment findActiveById(Long commentNo) { + Comment comment = commentRepository.findById(commentNo) + .orElseThrow(() -> new RestApiException(GlobalErrorStatus._NOT_FOUND)); + + if (comment.isDeleted()) + throw new RestApiException(GlobalErrorStatus._BAD_REQUEST); + + return comment; + } + + public Comment updateComment(Comment comment, String content) { + comment.updateContent(content); + return comment; + } + + public void softDelete(Comment comment) { + comment.delete(); + commentRepository.save(comment); + } +} diff --git a/src/main/java/com/project/dorumdorum/domain/notice/service/NoticeService.java b/src/main/java/com/project/dorumdorum/domain/notice/service/NoticeService.java new file mode 100644 index 0000000..5c5af51 --- /dev/null +++ b/src/main/java/com/project/dorumdorum/domain/notice/service/NoticeService.java @@ -0,0 +1,62 @@ +package com.project.dorumdorum.domain.notice.service; + +import com.project.dorumdorum.domain.notice.application.dto.request.UpdateNoticeRequest; +import com.project.dorumdorum.domain.notice.application.dto.request.WriteNoticeRequest; +import com.project.dorumdorum.domain.notice.application.dto.response.NoticesResponse; +import com.project.dorumdorum.domain.notice.domain.entity.Notice; +import com.project.dorumdorum.domain.notice.domain.repository.NoticeRepository; +import com.project.dorumdorum.global.exception.RestApiException; +import com.project.dorumdorum.global.exception.code.status.GlobalErrorStatus; +import jakarta.validation.constraints.NotNull; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +import java.util.List; + +@Service +@RequiredArgsConstructor +public class NoticeService { + + private final NoticeRepository noticeRepository; + + + public Notice writeNotice(Long userNo, Long roomNo, WriteNoticeRequest request) { + Notice entity = Notice.builder() + .roomNo(roomNo) + .userNo(userNo) + .title(request.title()) + .content(request.content()) + .build(); + + return noticeRepository.save(entity); + } + + + public List loadNoticeList(Long roomNo) { + List notices = noticeRepository.findByRoomNo(roomNo); + + return notices.stream() + .map(NoticesResponse::create) + .toList(); + } + + public Notice findById(@NotNull Long noticeNo) { + return noticeRepository.findById(noticeNo) + .orElseThrow(() -> new RestApiException(GlobalErrorStatus.NOTICE_NOT_FOUND)); + } + + public Notice updateNotice(Notice notice, UpdateNoticeRequest request) { + notice.update(request.title(), request.content()); + return notice; + } + + public void softDelete(Notice notice) { + notice.delete(); + noticeRepository.save(notice); + } + + public void deleteNotice(Long noticeNo) { + Notice notice = findById(noticeNo); + softDelete(notice); + } +} diff --git a/src/main/java/com/project/dorumdorum/domain/notice/ui/DeleteCommentController.java b/src/main/java/com/project/dorumdorum/domain/notice/ui/DeleteCommentController.java new file mode 100644 index 0000000..b639f60 --- /dev/null +++ b/src/main/java/com/project/dorumdorum/domain/notice/ui/DeleteCommentController.java @@ -0,0 +1,27 @@ +package com.project.dorumdorum.domain.notice.ui; + +import com.project.dorumdorum.domain.notice.application.usecase.DeleteCommentUseCase; +import com.project.dorumdorum.domain.notice.ui.spec.DeleteCommentApiSpec; +import com.project.dorumdorum.global.annotation.CurrentUser; +import com.project.dorumdorum.global.common.BaseResponse; +import lombok.AccessLevel; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequiredArgsConstructor(access = AccessLevel.PROTECTED) +public class DeleteCommentController implements DeleteCommentApiSpec { + + private final DeleteCommentUseCase deleteCommentUseCase; + + @Override + public BaseResponse deleteComment( + @CurrentUser Long userNo, + @RequestParam Long commentNo + ) { + deleteCommentUseCase.execute(userNo, commentNo); + return BaseResponse.onSuccess(); + } +} + diff --git a/src/main/java/com/project/dorumdorum/domain/notice/ui/DeleteNoticeController.java b/src/main/java/com/project/dorumdorum/domain/notice/ui/DeleteNoticeController.java new file mode 100644 index 0000000..c6f3683 --- /dev/null +++ b/src/main/java/com/project/dorumdorum/domain/notice/ui/DeleteNoticeController.java @@ -0,0 +1,26 @@ +package com.project.dorumdorum.domain.notice.ui; + + +import com.project.dorumdorum.domain.notice.application.usecase.DeleteNoticeUseCase; +import com.project.dorumdorum.domain.notice.ui.spec.DeleteNoticeApiSpec; +import com.project.dorumdorum.global.annotation.CurrentUser; +import com.project.dorumdorum.global.common.BaseResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequiredArgsConstructor +public class DeleteNoticeController implements DeleteNoticeApiSpec { + + private final DeleteNoticeUseCase deleteNoticeUseCase; + + @Override + public BaseResponse deleteNotice( + @CurrentUser Long userNo, + @RequestParam Long noticeNo + ) { + deleteNoticeUseCase.execute(userNo, noticeNo); + return BaseResponse.onSuccess(); + } +} diff --git a/src/main/java/com/project/dorumdorum/domain/notice/ui/LoadNoticesController.java b/src/main/java/com/project/dorumdorum/domain/notice/ui/LoadNoticesController.java new file mode 100644 index 0000000..b2152a4 --- /dev/null +++ b/src/main/java/com/project/dorumdorum/domain/notice/ui/LoadNoticesController.java @@ -0,0 +1,38 @@ +package com.project.dorumdorum.domain.notice.ui; + + +import com.project.dorumdorum.domain.notice.application.dto.response.NoticeResponse; +import com.project.dorumdorum.domain.notice.application.dto.response.NoticesResponse; +import com.project.dorumdorum.domain.notice.application.usecase.LoadNoticesUseCase; +import com.project.dorumdorum.domain.notice.ui.spec.LoadNoticesApiSpec; +import com.project.dorumdorum.global.annotation.CurrentUser; +import com.project.dorumdorum.global.common.BaseResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; + +@RestController +@RequiredArgsConstructor +public class LoadNoticesController implements LoadNoticesApiSpec { + + private final LoadNoticesUseCase loadNoticesUseCase; + + @Override + public BaseResponse> loadNotices( + @CurrentUser Long userNo, + @RequestParam Long roomNo + ) { + return BaseResponse.onSuccess(loadNoticesUseCase.loadNotices(userNo, roomNo)); + } + + @Override + public BaseResponse loadNotice( + @CurrentUser Long userNo, + @RequestParam Long noticeNo + ) { + return BaseResponse.onSuccess(loadNoticesUseCase.loadNotice(userNo, noticeNo)); + } + +} diff --git a/src/main/java/com/project/dorumdorum/domain/notice/ui/UpdateCommentController.java b/src/main/java/com/project/dorumdorum/domain/notice/ui/UpdateCommentController.java new file mode 100644 index 0000000..46f8914 --- /dev/null +++ b/src/main/java/com/project/dorumdorum/domain/notice/ui/UpdateCommentController.java @@ -0,0 +1,29 @@ +package com.project.dorumdorum.domain.notice.ui; + +import com.project.dorumdorum.domain.notice.application.dto.request.UpdateCommentRequest; +import com.project.dorumdorum.domain.notice.application.dto.response.CommentResponse; +import com.project.dorumdorum.domain.notice.application.usecase.UpdateCommentUseCase; +import com.project.dorumdorum.domain.notice.ui.spec.UpdateCommentApiSpec; +import com.project.dorumdorum.global.annotation.CurrentUser; +import com.project.dorumdorum.global.common.BaseResponse; +import jakarta.validation.Valid; +import lombok.AccessLevel; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequiredArgsConstructor(access = AccessLevel.PROTECTED) +public class UpdateCommentController implements UpdateCommentApiSpec { + + private final UpdateCommentUseCase updateCommentUseCase; + + @Override + public BaseResponse updateComment( + @CurrentUser Long userNo, + @Valid @RequestBody UpdateCommentRequest request + ) { + return BaseResponse.onSuccess(updateCommentUseCase.execute(userNo, request)); + } +} + diff --git a/src/main/java/com/project/dorumdorum/domain/notice/ui/UpdateNoticeController.java b/src/main/java/com/project/dorumdorum/domain/notice/ui/UpdateNoticeController.java new file mode 100644 index 0000000..8359834 --- /dev/null +++ b/src/main/java/com/project/dorumdorum/domain/notice/ui/UpdateNoticeController.java @@ -0,0 +1,26 @@ +package com.project.dorumdorum.domain.notice.ui; + +import com.project.dorumdorum.domain.notice.application.dto.request.UpdateNoticeRequest; +import com.project.dorumdorum.domain.notice.application.dto.response.NoticeResponse; +import com.project.dorumdorum.domain.notice.application.usecase.UpdateNoticeUseCase; +import com.project.dorumdorum.domain.notice.ui.spec.UpdateNoticeApiSpec; +import com.project.dorumdorum.global.annotation.CurrentUser; +import com.project.dorumdorum.global.common.BaseResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequiredArgsConstructor +public class UpdateNoticeController implements UpdateNoticeApiSpec { + + private final UpdateNoticeUseCase updateNoticeUseCase; + + @Override + public BaseResponse updateNotice( + @CurrentUser Long userNo, + @RequestBody UpdateNoticeRequest request + ) { + return BaseResponse.onSuccess(updateNoticeUseCase.execute(userNo, request)); + } +} diff --git a/src/main/java/com/project/dorumdorum/domain/notice/ui/WriteCommentController.java b/src/main/java/com/project/dorumdorum/domain/notice/ui/WriteCommentController.java new file mode 100644 index 0000000..ec30a8c --- /dev/null +++ b/src/main/java/com/project/dorumdorum/domain/notice/ui/WriteCommentController.java @@ -0,0 +1,29 @@ +package com.project.dorumdorum.domain.notice.ui; + + +import com.project.dorumdorum.domain.notice.application.dto.request.WriteCommentRequest; +import com.project.dorumdorum.domain.notice.application.dto.response.CommentResponse; +import com.project.dorumdorum.domain.notice.application.usecase.WriteCommentUseCase; +import com.project.dorumdorum.domain.notice.ui.spec.WriteCommentApiSpec; +import com.project.dorumdorum.global.annotation.CurrentUser; +import com.project.dorumdorum.global.common.BaseResponse; +import jakarta.validation.Valid; +import lombok.AccessLevel; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequiredArgsConstructor(access = AccessLevel.PROTECTED) +public class WriteCommentController implements WriteCommentApiSpec { + + private final WriteCommentUseCase writeCommentUseCase; + + @Override + public BaseResponse writeComment( + @CurrentUser Long userNo, + @Valid @RequestBody WriteCommentRequest request + ) { + return BaseResponse.onSuccess(writeCommentUseCase.execute(userNo, request)); + } +} diff --git a/src/main/java/com/project/dorumdorum/domain/notice/ui/WriteNoticeController.java b/src/main/java/com/project/dorumdorum/domain/notice/ui/WriteNoticeController.java new file mode 100644 index 0000000..8a07c19 --- /dev/null +++ b/src/main/java/com/project/dorumdorum/domain/notice/ui/WriteNoticeController.java @@ -0,0 +1,27 @@ +package com.project.dorumdorum.domain.notice.ui; + +import com.project.dorumdorum.domain.notice.application.dto.request.WriteNoticeRequest; +import com.project.dorumdorum.domain.notice.application.dto.response.NoticeResponse; +import com.project.dorumdorum.domain.notice.application.usecase.WriteNoticeUseCase; +import com.project.dorumdorum.domain.notice.ui.spec.WriteNoticeApiSpec; +import com.project.dorumdorum.global.annotation.CurrentUser; +import com.project.dorumdorum.global.common.BaseResponse; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequiredArgsConstructor +public class WriteNoticeController implements WriteNoticeApiSpec { + + private final WriteNoticeUseCase writeNoticeUseCase; + + @Override + public BaseResponse writeNotice( + @CurrentUser Long userNo, + @RequestBody @Valid WriteNoticeRequest request + ) { + return BaseResponse.onSuccess(writeNoticeUseCase.execute(userNo, request)); + } +} diff --git a/src/main/java/com/project/dorumdorum/domain/notice/ui/spec/DeleteCommentApiSpec.java b/src/main/java/com/project/dorumdorum/domain/notice/ui/spec/DeleteCommentApiSpec.java new file mode 100644 index 0000000..59dbdfc --- /dev/null +++ b/src/main/java/com/project/dorumdorum/domain/notice/ui/spec/DeleteCommentApiSpec.java @@ -0,0 +1,25 @@ +package com.project.dorumdorum.domain.notice.ui.spec; + +import com.project.dorumdorum.global.common.BaseResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.RequestParam; + +@Tag(name = "Notice Comment") +public interface DeleteCommentApiSpec { + + @Operation( + summary = "댓글 삭제", + description = "댓글 소프트 삭제" + ) + @DeleteMapping("/api/comment") + BaseResponse deleteComment( + @Parameter(hidden = true) + Long userNo, + @Parameter(description = "댓글 번호", required = true) + @RequestParam Long commentNo + ); +} + diff --git a/src/main/java/com/project/dorumdorum/domain/notice/ui/spec/DeleteNoticeApiSpec.java b/src/main/java/com/project/dorumdorum/domain/notice/ui/spec/DeleteNoticeApiSpec.java new file mode 100644 index 0000000..45a103e --- /dev/null +++ b/src/main/java/com/project/dorumdorum/domain/notice/ui/spec/DeleteNoticeApiSpec.java @@ -0,0 +1,25 @@ +package com.project.dorumdorum.domain.notice.ui.spec; + +import com.project.dorumdorum.global.common.BaseResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.RequestParam; + +@Tag(name = "Notice") +public interface DeleteNoticeApiSpec { + + @Operation( + summary = "공지 삭제", + description = "공지 소프트 삭제 + 연결 이미지 소프트 삭제, S3 오브젝트는 즉시 삭제" + ) + @DeleteMapping("/api/notice") + BaseResponse deleteNotice( + @Parameter(hidden = true) + Long userNo, + @Parameter(description = "공지 번호", required = true) + @RequestParam Long noticeNo + ); +} + diff --git a/src/main/java/com/project/dorumdorum/domain/notice/ui/spec/LoadNoticesApiSpec.java b/src/main/java/com/project/dorumdorum/domain/notice/ui/spec/LoadNoticesApiSpec.java new file mode 100644 index 0000000..6a5e2a2 --- /dev/null +++ b/src/main/java/com/project/dorumdorum/domain/notice/ui/spec/LoadNoticesApiSpec.java @@ -0,0 +1,41 @@ +package com.project.dorumdorum.domain.notice.ui.spec; + +import com.project.dorumdorum.domain.notice.application.dto.response.NoticeResponse; +import com.project.dorumdorum.domain.notice.application.dto.response.NoticesResponse; +import com.project.dorumdorum.global.common.BaseResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestParam; + +import java.util.List; + +@Tag(name = "Notice") +public interface LoadNoticesApiSpec { + + @Operation( + summary = "공지 목록 조회", + description = "방 단위 공지 리스트 조회" + ) + @GetMapping("/api/notices") + BaseResponse> loadNotices( + @Parameter(hidden = true) + Long userNo, + @Parameter(description = "방 번호", required = true) + @RequestParam Long roomNo + ); + + @Operation( + summary = "공지 상세 조회", + description = "공지 상세 + 댓글 목록 + 이미지 조회 presigned URL 반환" + ) + @GetMapping("/api/notice") + BaseResponse loadNotice( + @Parameter(hidden = true) + Long userNo, + @Parameter(description = "공지 번호", required = true) + @RequestParam Long noticeNo + ); +} + diff --git a/src/main/java/com/project/dorumdorum/domain/notice/ui/spec/UpdateCommentApiSpec.java b/src/main/java/com/project/dorumdorum/domain/notice/ui/spec/UpdateCommentApiSpec.java new file mode 100644 index 0000000..2e1ec73 --- /dev/null +++ b/src/main/java/com/project/dorumdorum/domain/notice/ui/spec/UpdateCommentApiSpec.java @@ -0,0 +1,34 @@ +package com.project.dorumdorum.domain.notice.ui.spec; + +import com.project.dorumdorum.domain.notice.application.dto.request.UpdateCommentRequest; +import com.project.dorumdorum.domain.notice.application.dto.response.CommentResponse; +import com.project.dorumdorum.global.common.BaseResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.parameters.RequestBody; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.web.bind.annotation.PatchMapping; + +@Tag(name = "Notice Comment") +public interface UpdateCommentApiSpec { + + @Operation( + summary = "댓글 수정", + description = "댓글 내용 수정" + ) + @PatchMapping("/api/comment") + BaseResponse updateComment( + @Parameter(hidden = true) + Long userNo, + @RequestBody( + description = """ + 댓글 수정 요청 + - commentNo: 댓글 번호 + - content: 수정 내용 + """, + required = true + ) + UpdateCommentRequest request + ); +} + diff --git a/src/main/java/com/project/dorumdorum/domain/notice/ui/spec/UpdateNoticeApiSpec.java b/src/main/java/com/project/dorumdorum/domain/notice/ui/spec/UpdateNoticeApiSpec.java new file mode 100644 index 0000000..6453e2f --- /dev/null +++ b/src/main/java/com/project/dorumdorum/domain/notice/ui/spec/UpdateNoticeApiSpec.java @@ -0,0 +1,40 @@ +package com.project.dorumdorum.domain.notice.ui.spec; + +import com.project.dorumdorum.domain.notice.application.dto.request.UpdateNoticeRequest; +import com.project.dorumdorum.domain.notice.application.dto.response.NoticeResponse; +import com.project.dorumdorum.global.common.BaseResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.parameters.RequestBody; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.web.bind.annotation.PatchMapping; + +@Tag(name = "Notice") +public interface UpdateNoticeApiSpec { + + @Operation( + summary = "공지 수정", + description = """ + 공지 내용 수정 및 이미지 교체/삭제 + - 새 이미지 업로드: imageFileName + imageFileSize 제공 → 새 presigned URL 발급 + - 기존 이미지 삭제: deleteImage=true + """ + ) + @PatchMapping("/api/notice") + BaseResponse updateNotice( + @Parameter(hidden = true) + Long userNo, + @RequestBody( + description = """ + 공지 수정 요청 + - roomNo, noticeNo: 식별자 + - title, content: 수정 내용 + - imageFileName/imageFileSize: 새 이미지 교체 시 + - deleteImage: 기존 이미지만 삭제 시 true + """, + required = true + ) + UpdateNoticeRequest request + ); +} + diff --git a/src/main/java/com/project/dorumdorum/domain/notice/ui/spec/WriteCommentApiSpec.java b/src/main/java/com/project/dorumdorum/domain/notice/ui/spec/WriteCommentApiSpec.java new file mode 100644 index 0000000..fe5a1c4 --- /dev/null +++ b/src/main/java/com/project/dorumdorum/domain/notice/ui/spec/WriteCommentApiSpec.java @@ -0,0 +1,34 @@ +package com.project.dorumdorum.domain.notice.ui.spec; + +import com.project.dorumdorum.domain.notice.application.dto.request.WriteCommentRequest; +import com.project.dorumdorum.domain.notice.application.dto.response.CommentResponse; +import com.project.dorumdorum.global.common.BaseResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.parameters.RequestBody; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.web.bind.annotation.PostMapping; + +@Tag(name = "Notice Comment") +public interface WriteCommentApiSpec { + + @Operation( + summary = "댓글 작성", + description = "공지에 대한 댓글 등록" + ) + @PostMapping("/api/comment") + BaseResponse writeComment( + @Parameter(hidden = true) + Long userNo, + @RequestBody( + description = """ + 댓글 작성 요청 + - noticeNo: 공지 번호 + - content: 댓글 내용 + """, + required = true + ) + WriteCommentRequest request + ); +} + diff --git a/src/main/java/com/project/dorumdorum/domain/notice/ui/spec/WriteNoticeApiSpec.java b/src/main/java/com/project/dorumdorum/domain/notice/ui/spec/WriteNoticeApiSpec.java new file mode 100644 index 0000000..29c5fd0 --- /dev/null +++ b/src/main/java/com/project/dorumdorum/domain/notice/ui/spec/WriteNoticeApiSpec.java @@ -0,0 +1,37 @@ +package com.project.dorumdorum.domain.notice.ui.spec; + +import com.project.dorumdorum.domain.notice.application.dto.request.WriteNoticeRequest; +import com.project.dorumdorum.domain.notice.application.dto.response.NoticeResponse; +import com.project.dorumdorum.global.common.BaseResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.parameters.RequestBody; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.web.bind.annotation.PostMapping; + +@Tag(name = "Notice") +public interface WriteNoticeApiSpec { + + @Operation( + summary = "공지 작성", + description = "공지 생성 및 이미지 업로드 presigned URL 발급 (이미지 1개). " + + "요청에 imageFileName, imageFileSize를 주면 업로드 URL이 응답에 포함됩니다." + ) + @PostMapping("/api/notice") + BaseResponse writeNotice( + @Parameter(hidden = true) + Long userNo, + @RequestBody( + description = """ + 공지 작성 요청 + - roomNo: 방 번호 + - title, content: 제목/내용 + - imageFileName: 업로드할 이미지 파일명 + - imageFileSize: 업로드할 이미지 크기(byte) + """, + required = true + ) + WriteNoticeRequest request + ); +} + diff --git a/src/main/java/com/project/dorumdorum/domain/room/application/dto/usecase/CreateRoomUseCase.java b/src/main/java/com/project/dorumdorum/domain/room/application/dto/usecase/CreateRoomUseCase.java index da2d49b..dba8b6e 100644 --- a/src/main/java/com/project/dorumdorum/domain/room/application/dto/usecase/CreateRoomUseCase.java +++ b/src/main/java/com/project/dorumdorum/domain/room/application/dto/usecase/CreateRoomUseCase.java @@ -22,7 +22,7 @@ public class CreateRoomUseCase { public void execute(Long userNo, RoomCreateRequest request) { userService.validateExistsById(userNo); - Room room = roomService.create(request); + Room room = roomService.create(request, userNo); roommateService.create(userNo, room, RoomRole.HOST); } } diff --git a/src/main/java/com/project/dorumdorum/domain/room/domain/service/RoomService.java b/src/main/java/com/project/dorumdorum/domain/room/domain/service/RoomService.java index 7e92065..de63a54 100644 --- a/src/main/java/com/project/dorumdorum/domain/room/domain/service/RoomService.java +++ b/src/main/java/com/project/dorumdorum/domain/room/domain/service/RoomService.java @@ -23,12 +23,13 @@ public class RoomService { private final RoomRepository roomRepository; - public Room create(RoomCreateRequest request) { + public Room create(RoomCreateRequest request, Long hostUserNo) { Room entity = Room.builder() .capacity(request.capacity()) .roomType(request.roomType()) .tags(request.tags()) .title(request.title()) + .hostUserNo(hostUserNo) .build(); return roomRepository.save(entity); diff --git a/src/main/java/com/project/dorumdorum/global/config/S3Config.java b/src/main/java/com/project/dorumdorum/global/config/S3Config.java new file mode 100644 index 0000000..ca2ca0f --- /dev/null +++ b/src/main/java/com/project/dorumdorum/global/config/S3Config.java @@ -0,0 +1,34 @@ +package com.project.dorumdorum.global.config; + +import com.project.dorumdorum.global.properties.S3Properties; +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import software.amazon.awssdk.auth.credentials.DefaultCredentialsProvider; +import software.amazon.awssdk.regions.Region; +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.presigner.S3Presigner; + +@Configuration +@RequiredArgsConstructor +public class S3Config { + + private final S3Properties s3Properties; + + @Bean + public S3Client s3Client() { + return S3Client.builder() + .region(Region.of(s3Properties.getRegion())) + .credentialsProvider(DefaultCredentialsProvider.create()) + .build(); + } + + @Bean + public S3Presigner s3Presigner() { + return S3Presigner.builder() + .region(Region.of(s3Properties.getRegion())) + .credentialsProvider(DefaultCredentialsProvider.create()) + .build(); + } +} + diff --git a/src/main/java/com/project/dorumdorum/global/exception/code/status/GlobalErrorStatus.java b/src/main/java/com/project/dorumdorum/global/exception/code/status/GlobalErrorStatus.java index 83c2258..209ce21 100644 --- a/src/main/java/com/project/dorumdorum/global/exception/code/status/GlobalErrorStatus.java +++ b/src/main/java/com/project/dorumdorum/global/exception/code/status/GlobalErrorStatus.java @@ -42,6 +42,7 @@ public enum GlobalErrorStatus implements BaseCodeInterface { ROOM_REQUEST_NOT_FOUND(HttpStatus.NOT_FOUND, "ROOM006", "존재하지 않는 요청입니다."), ROOM_IS_NOT_FULL(HttpStatus.BAD_REQUEST, "ROOM007", "방이 가득차지 않았습니다."), ALREADY_CONFIRM_REQUEST(HttpStatus.BAD_REQUEST, "ROOM008", "이미 처리된 요청입니다."), + USER_NOT_IN_ROOM(HttpStatus.BAD_REQUEST, "ROOM009", "멤버가 아닙니다."), // 이거 추가해도 될까요? // Friend @@ -54,6 +55,13 @@ public enum GlobalErrorStatus implements BaseCodeInterface { REQUEST_NOT_SENDER(HttpStatus.FORBIDDEN, "FRIEND007", "해당 친구추가 요청의 발신자가 아닙니다."), REQUEST_NOT_PENDING(HttpStatus.BAD_REQUEST, "FRIEND008", "해당 친구추가 요청이 대기 상태가 아닙니다."), + // Notice + TITLE_IS_EMPTY(HttpStatus.BAD_REQUEST, "NOTICE001", "제목이 비어있습니다."), + CONTENT_IS_EMPTY(HttpStatus.BAD_REQUEST, "NOTICE002" , "본문이 비어있습니다."), + NO_PERMISSION_ON_NOTICE(HttpStatus.BAD_REQUEST, "NOTICE003", "공지사항 편집 권한이 없습니다."), + NOTICE_NOT_FOUND(HttpStatus.BAD_REQUEST, "NOTICE004", "공지사항을 찾을 수 없습니다."), + NOTICE_ALREADY_DELETED(HttpStatus.BAD_REQUEST, "NOTICE005", "이미 삭제된 글입니다."), + // Notification FIREBASE_TOKEN_NOT_FOUND(HttpStatus.BAD_REQUEST, "NOTIFICATION001", "수신자의 FCM 토큰이 등록되어 있지 않습니다."), NOTIFICATION_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "NOTIFICATION002", "알림 전송 중 서버 에러") diff --git a/src/main/java/com/project/dorumdorum/global/properties/S3Properties.java b/src/main/java/com/project/dorumdorum/global/properties/S3Properties.java new file mode 100644 index 0000000..edd5333 --- /dev/null +++ b/src/main/java/com/project/dorumdorum/global/properties/S3Properties.java @@ -0,0 +1,22 @@ +package com.project.dorumdorum.global.properties; + +import lombok.Getter; +import lombok.Setter; +import org.springframework.boot.context.properties.ConfigurationProperties; + +@Getter +@Setter +@ConfigurationProperties(prefix = "aws.s3") +public class S3Properties { + + /** + * 업로드/다운로드에 사용할 S3 버킷 이름 + */ + private String bucket; + + /** + * 버킷이 위치한 리전(e.g. ap-northeast-2) + */ + private String region; +} + diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 41ef263..bd50f7c 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -123,4 +123,9 @@ include-email-domains: firebase: service-account: - path: "/firebase/dorumdorum-9ce75-firebase-adminsdk-fbsvc-8d66c0dccb.json" \ No newline at end of file + path: "/firebase/dorumdorum-9ce75-firebase-adminsdk-fbsvc-8d66c0dccb.json" + +aws: + s3: + bucket: ${AWS_S3_BUCKET} + region: ${AWS_REGION:ap-northeast-2} \ No newline at end of file