diff --git a/build.gradle b/build.gradle index b87517d6..c17d55e7 100644 --- a/build.gradle +++ b/build.gradle @@ -93,6 +93,11 @@ dependencies { // Spring aop implementation 'org.springframework.boot:spring-boot-starter-aop' + // S3 + implementation platform('software.amazon.awssdk:bom:2.23.7') + implementation 'software.amazon.awssdk:s3' + implementation 'ch.qos.logback:logback-classic:1.4.12' + } tasks.named('test') { diff --git a/src/main/java/clap/server/adapter/inbound/web/dto/task/AttachmentRequest.java b/src/main/java/clap/server/adapter/inbound/web/dto/task/AttachmentRequest.java deleted file mode 100644 index 96a038c7..00000000 --- a/src/main/java/clap/server/adapter/inbound/web/dto/task/AttachmentRequest.java +++ /dev/null @@ -1,13 +0,0 @@ -package clap.server.adapter.inbound.web.dto.task; - - -import io.swagger.v3.oas.annotations.media.Schema; - -public record AttachmentRequest( - @Schema(description = "파일 ID", example = "45") - Long fileId, - - @Schema(description = "파일 URL", example = "https://example.com/file.png") - String fileUrl -) { -} diff --git a/src/main/java/clap/server/adapter/inbound/web/dto/task/CreateTaskRequest.java b/src/main/java/clap/server/adapter/inbound/web/dto/task/CreateTaskRequest.java index df1a3512..423b3683 100644 --- a/src/main/java/clap/server/adapter/inbound/web/dto/task/CreateTaskRequest.java +++ b/src/main/java/clap/server/adapter/inbound/web/dto/task/CreateTaskRequest.java @@ -4,8 +4,6 @@ import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; -import java.util.List; - @Schema(description = "작업 생성 요청") public record CreateTaskRequest( @@ -13,18 +11,11 @@ public record CreateTaskRequest( @NotNull Long categoryId, - @Schema(description = "메인 카테고리 ID") - @NotNull - Long mainCategoryId, - @Schema(description = "작업 제목") @NotBlank String title, @Schema(description = "작업 설명") - String description, - - @Schema(description = "첨부 파일 URL 목록", example = "[\"https://example.com/file1.png\", \"https://example.com/file2.pdf\"]") - List<@NotBlank String> fileUrls + String description ) { } diff --git a/src/main/java/clap/server/adapter/inbound/web/dto/task/UpdateTaskRequest.java b/src/main/java/clap/server/adapter/inbound/web/dto/task/UpdateTaskRequest.java index 166bdb64..7451d3fd 100644 --- a/src/main/java/clap/server/adapter/inbound/web/dto/task/UpdateTaskRequest.java +++ b/src/main/java/clap/server/adapter/inbound/web/dto/task/UpdateTaskRequest.java @@ -9,18 +9,10 @@ @Schema(description = "작업 업데이트 요청") public record UpdateTaskRequest( - @Schema(description = "작업 ID", example = "123") - @NotNull - Long taskId, - @Schema(description = "카테고리 ID", example = "1") @NotNull Long categoryId, - @Schema(description = "메인 카테고리 ID", example = "10") - @NotNull - Long mainCategoryId, - @Schema(description = "작업 제목", example = "업데이트된 제목") @NotBlank String title, @@ -28,7 +20,8 @@ public record UpdateTaskRequest( @Schema(description = "작업 설명", example = "업데이트된 설명.") String description, - @Schema(description = "첨부 파일 요청 목록", implementation = AttachmentRequest.class) - List attachmentRequests + @Schema(description = "삭제할 파일 ID 목록, 없을 경우 emptylist 전송") + @NotNull + List attachmentsToDelete ) {} diff --git a/src/main/java/clap/server/adapter/inbound/web/task/ManagementTaskController.java b/src/main/java/clap/server/adapter/inbound/web/task/ManagementTaskController.java index a87f30bb..5e30f255 100644 --- a/src/main/java/clap/server/adapter/inbound/web/task/ManagementTaskController.java +++ b/src/main/java/clap/server/adapter/inbound/web/task/ManagementTaskController.java @@ -11,10 +11,15 @@ import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; +import jakarta.validation.constraints.NotNull; import lombok.RequiredArgsConstructor; +import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; + +import java.util.List; @Tag(name = "작업 생성 및 수정") @@ -28,18 +33,22 @@ public class ManagementTaskController { private final UpdateTaskUsecase updateTaskUsecase; @Operation(summary = "작업 요청 생성") - @PostMapping + @PostMapping(consumes = {MediaType.APPLICATION_JSON_VALUE, MediaType.MULTIPART_FORM_DATA_VALUE}) public ResponseEntity createTask( - @RequestBody @Valid CreateTaskRequest createTaskRequest, - @AuthenticationPrincipal SecurityUserDetails userInfo){ - return ResponseEntity.ok(createTaskUsecase.createTask(userInfo.getUserId(), createTaskRequest)); + @RequestPart(name = "taskInfo") @Valid CreateTaskRequest createTaskRequest, + @RequestPart(name = "attachment") @NotNull List attachments, + @AuthenticationPrincipal SecurityUserDetails userInfo + ){ + return ResponseEntity.ok(createTaskUsecase.createTask(userInfo.getUserId(), createTaskRequest, attachments)); } - @Operation(summary = "요청한 작업 수정") - @PatchMapping + @Operation(summary = "작업 수정") + @PatchMapping(value = "/{taskId}", consumes = {MediaType.APPLICATION_JSON_VALUE, MediaType.MULTIPART_FORM_DATA_VALUE}) public ResponseEntity updateTask( - @RequestBody @Valid UpdateTaskRequest updateTaskRequest, + @PathVariable @NotNull Long taskId, + @RequestPart(name = "taskInfo") @Valid UpdateTaskRequest updateTaskRequest, + @RequestPart(name = "attachment") @NotNull List attachments, @AuthenticationPrincipal SecurityUserDetails userInfo){ - return ResponseEntity.ok(updateTaskUsecase.updateTask(userInfo.getUserId(), updateTaskRequest)); + return ResponseEntity.ok(updateTaskUsecase.updateTask(userInfo.getUserId(), taskId, updateTaskRequest, attachments)); } } diff --git a/src/main/java/clap/server/adapter/outbound/infrastructure/s3/S3UploadAdapter.java b/src/main/java/clap/server/adapter/outbound/infrastructure/s3/S3UploadAdapter.java new file mode 100644 index 00000000..450dba10 --- /dev/null +++ b/src/main/java/clap/server/adapter/outbound/infrastructure/s3/S3UploadAdapter.java @@ -0,0 +1,73 @@ +package clap.server.adapter.outbound.infrastructure.s3; + +import clap.server.application.port.outbound.s3.S3UploadPort; +import clap.server.config.s3.KakaoS3Config; +import clap.server.domain.model.task.FilePath; +import clap.server.exception.S3Exception; +import clap.server.exception.code.S3Errorcode; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.web.multipart.MultipartFile; +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.model.PutObjectRequest; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.util.List; +import java.util.UUID; + +@Slf4j +@Service +@RequiredArgsConstructor +public class S3UploadAdapter implements S3UploadPort { + private final KakaoS3Config kakaoS3Config; + private final S3Client s3Client; + + public List uploadFiles(FilePath filePrefix, List multipartFiles) { + return multipartFiles.stream().map((file) -> uploadSingleFile(filePrefix, file)).toList(); + } + + public String uploadSingleFile(FilePath filePrefix, MultipartFile file) { + try { + Path filePath = getFilePath(file); + String objectKey = createObjectKey(filePrefix.getPath(), file.getOriginalFilename()); + uploadToS3(objectKey, filePath); + Files.delete(filePath); + return getFileUrl(objectKey); + } catch (IOException e) { + throw new S3Exception(S3Errorcode.FILE_UPLOAD_REQUEST_FAILED); + } + } + + private String getFileUrl(String objectKey) { + return kakaoS3Config.getEndpoint() + "/v1/" + kakaoS3Config.getProjectId() + '/' + kakaoS3Config.getBucketName() + '/' + objectKey; + } + + private static Path getFilePath(MultipartFile file) throws IOException { + Path path = Files.createTempFile(null,null); + Files.copy(file.getInputStream(),path, StandardCopyOption.REPLACE_EXISTING); + return path; + } + + private void uploadToS3(String filePath, Path path) { + PutObjectRequest putObjectRequest = PutObjectRequest.builder() + .bucket(kakaoS3Config.getBucketName()) + .key(filePath) + .build(); + + s3Client.putObject(putObjectRequest, path); + } + + private String createFileId() { + return UUID.randomUUID().toString(); + } + + private String createObjectKey(String filepath, String fileName) { + String fileId = createFileId(); + return String.format("%s/%s-%s", filepath, fileId , fileName); + } + +} diff --git a/src/main/java/clap/server/adapter/outbound/persistense/AttachmentPersistenceAdapter.java b/src/main/java/clap/server/adapter/outbound/persistense/AttachmentPersistenceAdapter.java index 6a08cc30..6fb71188 100644 --- a/src/main/java/clap/server/adapter/outbound/persistense/AttachmentPersistenceAdapter.java +++ b/src/main/java/clap/server/adapter/outbound/persistense/AttachmentPersistenceAdapter.java @@ -45,6 +45,13 @@ public List findAllByTaskIdAndCommentIsNull(final Long taskId) { .collect(Collectors.toList()); } + public List findAllByTaskIdAndCommentIsNullAndAttachmentId(final Long taskId, final List attachmentIds) { + List attachmentEntities = attachmentRepository.findAllByTask_TaskIdAndCommentIsNullAndAttachmentIdIn(taskId, attachmentIds); + return attachmentEntities.stream() + .map(attachmentPersistenceMapper::toDomain) + .collect(Collectors.toList()); + } + @Override public void deleteByIds(List attachmentIds) { attachmentRepository.deleteAllByAttachmentIdIn(attachmentIds); diff --git a/src/main/java/clap/server/adapter/outbound/persistense/entity/task/TaskEntity.java b/src/main/java/clap/server/adapter/outbound/persistense/entity/task/TaskEntity.java index 21ffde35..1dfe9961 100644 --- a/src/main/java/clap/server/adapter/outbound/persistense/entity/task/TaskEntity.java +++ b/src/main/java/clap/server/adapter/outbound/persistense/entity/task/TaskEntity.java @@ -10,8 +10,6 @@ import lombok.experimental.SuperBuilder; import java.time.LocalDateTime; -import java.util.ArrayList; -import java.util.List; @Entity @Table(name = "task") diff --git a/src/main/java/clap/server/adapter/outbound/persistense/repository/task/AttachmentRepository.java b/src/main/java/clap/server/adapter/outbound/persistense/repository/task/AttachmentRepository.java index fd7db8be..60e708f5 100644 --- a/src/main/java/clap/server/adapter/outbound/persistense/repository/task/AttachmentRepository.java +++ b/src/main/java/clap/server/adapter/outbound/persistense/repository/task/AttachmentRepository.java @@ -3,11 +3,13 @@ import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.stereotype.Repository; +import java.util.Collection; import java.util.List; @Repository public interface AttachmentRepository extends JpaRepository { List findAllByTask_TaskIdAndCommentIsNull(Long taskId); void deleteAllByAttachmentIdIn(List attachmentIds); + List findAllByTask_TaskIdAndCommentIsNullAndAttachmentIdIn(Long task_taskId, List attachmentId); } \ No newline at end of file diff --git a/src/main/java/clap/server/application/Task/CreateTaskService.java b/src/main/java/clap/server/application/Task/CreateTaskService.java index f1c8a46b..1ebedf16 100644 --- a/src/main/java/clap/server/application/Task/CreateTaskService.java +++ b/src/main/java/clap/server/application/Task/CreateTaskService.java @@ -2,28 +2,31 @@ import clap.server.adapter.inbound.web.dto.task.CreateTaskRequest; import clap.server.adapter.inbound.web.dto.task.CreateTaskResponse; - +import clap.server.adapter.outbound.infrastructure.s3.S3UploadAdapter; import clap.server.adapter.outbound.persistense.entity.notification.constant.NotificationType; +import clap.server.application.mapper.AttachmentMapper; import clap.server.application.mapper.TaskMapper; import clap.server.application.port.inbound.domain.CategoryService; import clap.server.application.port.inbound.domain.MemberService; import clap.server.application.port.inbound.task.CreateTaskUsecase; import clap.server.application.port.outbound.task.CommandAttachmentPort; import clap.server.application.port.outbound.task.CommandTaskPort; - import clap.server.common.annotation.architecture.ApplicationService; import clap.server.domain.model.member.Member; import clap.server.domain.model.notification.Notification; import clap.server.domain.model.task.Attachment; import clap.server.domain.model.task.Category; +import clap.server.domain.model.task.FilePath; import clap.server.domain.model.task.Task; import lombok.RequiredArgsConstructor; - import org.springframework.context.ApplicationEventPublisher; import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.multipart.MultipartFile; import java.util.List; +import static clap.server.domain.model.notification.Notification.createTaskNotification; + @ApplicationService @RequiredArgsConstructor @@ -33,37 +36,36 @@ public class CreateTaskService implements CreateTaskUsecase { private final CategoryService categoryService; private final CommandTaskPort commandTaskPort; private final CommandAttachmentPort commandAttachmentPort; + private final S3UploadAdapter s3UploadAdapter; private final ApplicationEventPublisher applicationEventPublisher; @Override @Transactional - public CreateTaskResponse createTask(Long requesterId, CreateTaskRequest createTaskRequest) { + public CreateTaskResponse createTask(Long requesterId, CreateTaskRequest createTaskRequest, List files) { Member member = memberService.findActiveMember(requesterId); Category category = categoryService.findById(createTaskRequest.categoryId()); Task task = Task.createTask(member, category, createTaskRequest.title(), createTaskRequest.description()); Task savedTask = commandTaskPort.save(task); - List attachments = Attachment.createAttachments(savedTask, createTaskRequest.fileUrls()); - commandAttachmentPort.saveAll(attachments); - + saveAttachments(files, savedTask); + publishNotification(savedTask); + return TaskMapper.toCreateTaskResponse(savedTask); + } - // requestDto에 알림 데이터 mapping + private void saveAttachments(List files, Task task) { + List fileUrls = s3UploadAdapter.uploadFiles(FilePath.TASK_IMAGE, files); + List attachments = AttachmentMapper.toTaskAttachments(task, files, fileUrls); + commandAttachmentPort.saveAll(attachments); + } + private void publishNotification(Task task){ List reviewers = memberService.findReviewers(); - // 검토자들 각각에 대한 알림 생성 후 event 발행 for (Member reviewer : reviewers) { - Notification notification = Notification.builder() - .task(savedTask) - .type(NotificationType.TASK_REQUESTED) - .receiver(reviewer) - .message(null) - .build(); - // publish event로 event 발행 + Notification notification = createTaskNotification(task, reviewer, NotificationType.TASK_REQUESTED); applicationEventPublisher.publishEvent(notification); } - - return TaskMapper.toCreateTaskResponse(savedTask); } -} + +} \ No newline at end of file diff --git a/src/main/java/clap/server/application/Task/UpdateTaskService.java b/src/main/java/clap/server/application/Task/UpdateTaskService.java index e83d1b7f..4bafb347 100644 --- a/src/main/java/clap/server/application/Task/UpdateTaskService.java +++ b/src/main/java/clap/server/application/Task/UpdateTaskService.java @@ -2,6 +2,7 @@ import clap.server.adapter.inbound.web.dto.task.UpdateTaskRequest; import clap.server.adapter.inbound.web.dto.task.UpdateTaskResponse; +import clap.server.adapter.outbound.infrastructure.s3.S3UploadAdapter; import clap.server.application.mapper.AttachmentMapper; import clap.server.application.mapper.TaskMapper; import clap.server.application.port.inbound.domain.CategoryService; @@ -10,16 +11,18 @@ import clap.server.application.port.inbound.task.UpdateTaskUsecase; import clap.server.application.port.outbound.task.CommandAttachmentPort; import clap.server.application.port.outbound.task.CommandTaskPort; - +import clap.server.application.port.outbound.task.LoadAttachmentPort; import clap.server.common.annotation.architecture.ApplicationService; -import clap.server.domain.model.member.Member; import clap.server.domain.model.task.Attachment; import clap.server.domain.model.task.Category; +import clap.server.domain.model.task.FilePath; import clap.server.domain.model.task.Task; - +import clap.server.exception.ApplicationException; +import clap.server.exception.code.TaskErrorCode; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.multipart.MultipartFile; import java.util.List; @@ -34,24 +37,39 @@ public class UpdateTaskService implements UpdateTaskUsecase { private final CategoryService categoryService; private final TaskService taskService; private final CommandTaskPort commandTaskPort; + private final LoadAttachmentPort loadAttachmentPort; private final CommandAttachmentPort commandAttachmentPort; - + private final S3UploadAdapter s3UploadAdapter; @Override @Transactional - public UpdateTaskResponse updateTask(Long requesterId, UpdateTaskRequest updateTaskRequest) { + public UpdateTaskResponse updateTask(Long requesterId, Long taskId, UpdateTaskRequest updateTaskRequest, List files) { memberService.findActiveMember(requesterId); Category category = categoryService.findById(updateTaskRequest.categoryId()); - Task task = taskService.findById(updateTaskRequest.taskId()); + Task task = taskService.findById(taskId); //TODO: 작업이 요청 상태인 경우만 업데이트 가능 task.updateTask(category, updateTaskRequest.title(), updateTaskRequest.description()); Task updatedTask = commandTaskPort.save(task); - List attachmentIds = AttachmentMapper.toAttachmentIds(updateTaskRequest.attachmentRequests()); - commandAttachmentPort.deleteByIds(attachmentIds); + if (!updateTaskRequest.attachmentsToDelete().isEmpty()){ + updateAttachments(updateTaskRequest.attachmentsToDelete(), files, task); + } + return TaskMapper.toUpdateTaskResponse(updatedTask); + } - List attachments = Attachment.updateAttachments(updatedTask, updateTaskRequest.attachmentRequests()); + private void updateAttachments(List attachmentIdsToDelete, List files, Task task) { + validateAttachments(attachmentIdsToDelete, task); + commandAttachmentPort.deleteByIds(attachmentIdsToDelete); + + List fileUrls = s3UploadAdapter.uploadFiles(FilePath.TASK_IMAGE, files); + List attachments = AttachmentMapper.toTaskAttachments(task, files, fileUrls); commandAttachmentPort.saveAll(attachments); - return TaskMapper.toUpdateTaskResponse(updatedTask); + } + + private void validateAttachments(List attachmentIdsToDelete, Task task) { + List attachmentsOfTask = loadAttachmentPort.findAllByTaskIdAndCommentIsNullAndAttachmentId(task.getTaskId(), attachmentIdsToDelete); + if(attachmentsOfTask.size() != attachmentIdsToDelete.size()) { + throw new ApplicationException(TaskErrorCode.TASK_ATTACHMENT_NOT_FOUND); + } } } diff --git a/src/main/java/clap/server/application/mapper/AttachmentMapper.java b/src/main/java/clap/server/application/mapper/AttachmentMapper.java index 9387bbad..121dd9fe 100644 --- a/src/main/java/clap/server/application/mapper/AttachmentMapper.java +++ b/src/main/java/clap/server/application/mapper/AttachmentMapper.java @@ -1,24 +1,27 @@ package clap.server.application.mapper; -import clap.server.adapter.inbound.web.dto.task.AttachmentRequest; -import clap.server.adapter.inbound.web.dto.task.AttachmentResponse; -import clap.server.adapter.inbound.web.dto.task.FindTaskDetailsResponse; import clap.server.domain.model.task.Attachment; import clap.server.domain.model.task.Task; +import org.springframework.web.multipart.MultipartFile; import java.util.List; -import java.util.Objects; -import java.util.stream.Collectors; +import java.util.stream.IntStream; + +import static clap.server.domain.model.task.Attachment.createAttachment; public class AttachmentMapper { private AttachmentMapper() { throw new IllegalArgumentException(); } - public static List toAttachmentIds(List attachmentRequests) { - return attachmentRequests.stream() - .map(AttachmentRequest::fileId) - .filter(Objects::nonNull) + public static List toTaskAttachments(Task task, List files, List fileUrls) { + return IntStream.range(0, files.size()) + .mapToObj(i -> createAttachment( + task, + files.get(i).getOriginalFilename(), + fileUrls.get(i), + files.get(i).getSize() + )) .toList(); } diff --git a/src/main/java/clap/server/application/mapper/MemberInfoMapper.java b/src/main/java/clap/server/application/mapper/MemberInfoMapper.java deleted file mode 100644 index 647c6b81..00000000 --- a/src/main/java/clap/server/application/mapper/MemberInfoMapper.java +++ /dev/null @@ -1,24 +0,0 @@ -package clap.server.application.mapper; - -import clap.server.adapter.outbound.persistense.entity.member.constant.MemberRole; -import clap.server.domain.model.member.Department; -import clap.server.domain.model.member.MemberInfo; - -public class MemberInfoMapper { - private MemberInfoMapper() { - throw new IllegalArgumentException(); - } - - public static MemberInfo toMemberInfo(String name, String email, String nickname, boolean isReviewer, - Department department, MemberRole role, String departmentRole) { - return MemberInfo.builder() - .name(name) - .email(email) - .nickname(nickname) - .isReviewer(isReviewer) - .department(department) - .role(role) - .departmentRole(departmentRole) - .build(); - } -} diff --git a/src/main/java/clap/server/application/mapper/MemberMapper.java b/src/main/java/clap/server/application/mapper/MemberMapper.java index 6f23c923..2dace59d 100644 --- a/src/main/java/clap/server/application/mapper/MemberMapper.java +++ b/src/main/java/clap/server/application/mapper/MemberMapper.java @@ -1,5 +1,8 @@ package clap.server.application.mapper; + +import clap.server.adapter.outbound.persistense.entity.member.constant.MemberRole; +import clap.server.domain.model.member.Department; import clap.server.adapter.inbound.web.dto.member.MemberProfileResponse; import clap.server.domain.model.member.Member; import clap.server.domain.model.member.MemberInfo; @@ -15,6 +18,19 @@ public static Member toMember(MemberInfo memberInfo) { .build(); } + public static MemberInfo toMemberInfo(String name, String email, String nickname, boolean isReviewer, + Department department, MemberRole role, String departmentRole) { + return MemberInfo.builder() + .name(name) + .email(email) + .nickname(nickname) + .isReviewer(isReviewer) + .department(department) + .role(role) + .departmentRole(departmentRole) + .build(); + } + public static MemberProfileResponse toMemberProfileResponse(Member member) { return new MemberProfileResponse( member.getMemberId(), diff --git a/src/main/java/clap/server/application/mapper/TaskMapper.java b/src/main/java/clap/server/application/mapper/TaskMapper.java index 7fce4e8f..b491aaf5 100644 --- a/src/main/java/clap/server/application/mapper/TaskMapper.java +++ b/src/main/java/clap/server/application/mapper/TaskMapper.java @@ -2,9 +2,7 @@ import clap.server.adapter.inbound.web.dto.task.*; - import clap.server.domain.model.task.Attachment; - import clap.server.domain.model.task.Task; import java.util.List; @@ -14,6 +12,7 @@ public class TaskMapper { private TaskMapper() { throw new IllegalArgumentException(); } + public static CreateTaskResponse toCreateTaskResponse(Task task) { return new CreateTaskResponse(task.getTaskId(), task.getCategory().getCategoryId(), task.getTitle()); } diff --git a/src/main/java/clap/server/application/port/inbound/task/CreateTaskUsecase.java b/src/main/java/clap/server/application/port/inbound/task/CreateTaskUsecase.java index 9529e066..758c0476 100644 --- a/src/main/java/clap/server/application/port/inbound/task/CreateTaskUsecase.java +++ b/src/main/java/clap/server/application/port/inbound/task/CreateTaskUsecase.java @@ -2,7 +2,10 @@ import clap.server.adapter.inbound.web.dto.task.CreateTaskRequest; import clap.server.adapter.inbound.web.dto.task.CreateTaskResponse; +import org.springframework.web.multipart.MultipartFile; + +import java.util.List; public interface CreateTaskUsecase { - CreateTaskResponse createTask(Long memberId, CreateTaskRequest createTaskRequest); + CreateTaskResponse createTask(Long requesterId, CreateTaskRequest createTaskRequest, List files); } diff --git a/src/main/java/clap/server/application/port/inbound/task/UpdateTaskUsecase.java b/src/main/java/clap/server/application/port/inbound/task/UpdateTaskUsecase.java index 18d27670..ee3f6f98 100644 --- a/src/main/java/clap/server/application/port/inbound/task/UpdateTaskUsecase.java +++ b/src/main/java/clap/server/application/port/inbound/task/UpdateTaskUsecase.java @@ -3,7 +3,10 @@ import clap.server.adapter.inbound.web.dto.task.UpdateTaskRequest; import clap.server.adapter.inbound.web.dto.task.UpdateTaskResponse; +import org.springframework.web.multipart.MultipartFile; + +import java.util.List; public interface UpdateTaskUsecase { - UpdateTaskResponse updateTask(Long memberId, UpdateTaskRequest updateTaskRequest); + UpdateTaskResponse updateTask(Long memberId, Long taskId, UpdateTaskRequest updateTaskRequest, List files); } diff --git a/src/main/java/clap/server/application/port/outbound/s3/S3UploadPort.java b/src/main/java/clap/server/application/port/outbound/s3/S3UploadPort.java new file mode 100644 index 00000000..a55f06a9 --- /dev/null +++ b/src/main/java/clap/server/application/port/outbound/s3/S3UploadPort.java @@ -0,0 +1,12 @@ +package clap.server.application.port.outbound.s3; + +import clap.server.domain.model.task.FilePath; +import org.springframework.web.multipart.MultipartFile; + +import java.util.List; + +public interface S3UploadPort { + List uploadFiles(FilePath filePrefix, List multipartFiles); + + String uploadSingleFile(FilePath filePrefix, MultipartFile file); +} diff --git a/src/main/java/clap/server/application/port/outbound/task/LoadAttachmentPort.java b/src/main/java/clap/server/application/port/outbound/task/LoadAttachmentPort.java index 6796877d..7b2f350f 100644 --- a/src/main/java/clap/server/application/port/outbound/task/LoadAttachmentPort.java +++ b/src/main/java/clap/server/application/port/outbound/task/LoadAttachmentPort.java @@ -7,4 +7,5 @@ public interface LoadAttachmentPort { List findAllByTaskIdAndCommentIsNull(Long task); + List findAllByTaskIdAndCommentIsNullAndAttachmentId(Long taskId, List attachmentIds); } diff --git a/src/main/java/clap/server/application/service/admin/RegisterMemberService.java b/src/main/java/clap/server/application/service/admin/RegisterMemberService.java index 906cf06b..3e163d3b 100644 --- a/src/main/java/clap/server/application/service/admin/RegisterMemberService.java +++ b/src/main/java/clap/server/application/service/admin/RegisterMemberService.java @@ -15,7 +15,7 @@ import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.transaction.annotation.Transactional; -import static clap.server.application.mapper.MemberInfoMapper.toMemberInfo; +import static clap.server.application.mapper.MemberMapper.toMemberInfo; import static clap.server.application.mapper.MemberMapper.toMember; @ApplicationService diff --git a/src/main/java/clap/server/config/s3/KakaoS3Config.java b/src/main/java/clap/server/config/s3/KakaoS3Config.java new file mode 100644 index 00000000..649b1b3d --- /dev/null +++ b/src/main/java/clap/server/config/s3/KakaoS3Config.java @@ -0,0 +1,56 @@ +package clap.server.config.s3; + +import lombok.Getter; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import software.amazon.awssdk.auth.credentials.AwsBasicCredentials; +import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider; +import software.amazon.awssdk.regions.Region; +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.S3Configuration; +import software.amazon.awssdk.services.s3.presigner.S3Presigner; + +import java.net.URI; + +@Configuration +public class KakaoS3Config { + + @Getter + private final String projectId; + @Getter + private final String endpoint; + private final String accessKey; + private final String secretKey; + private final String region; + @Getter + private String bucketName; + + public KakaoS3Config( + @Value("${cloud.kakao.project-id}") String projectId, + @Value("${cloud.kakao.object-storage.endpoint}") String endpoint, + @Value("${cloud.kakao.object-storage.access-key}") String accessKey, + @Value("${cloud.kakao.object-storage.secret-key}") String secretKey, + @Value("${cloud.kakao.region}") String region, + @Value("${cloud.kakao.object-storage.bucket-name}") String bucketName + ) { + this.projectId = projectId; + this.endpoint = endpoint; + this.accessKey = accessKey; + this.secretKey = secretKey; + this.region = region; + this.bucketName = bucketName; + } + + + @Bean + public S3Client s3Client() { + return S3Client.builder() + .region(Region.of(region)) + .credentialsProvider(StaticCredentialsProvider.create( + AwsBasicCredentials.create(accessKey, secretKey))) + .endpointOverride(URI.create(endpoint)) + .forcePathStyle(true) + .build(); + } +} diff --git a/src/main/java/clap/server/domain/model/notification/Notification.java b/src/main/java/clap/server/domain/model/notification/Notification.java index 91ae10f9..26001af7 100644 --- a/src/main/java/clap/server/domain/model/notification/Notification.java +++ b/src/main/java/clap/server/domain/model/notification/Notification.java @@ -34,4 +34,13 @@ public Notification(Task task, NotificationType type, Member receiver, String me this.message = message; this.isRead = false; } + + public static Notification createTaskNotification(Task task, Member reviewer, NotificationType type) { + return Notification.builder() + .task(task) + .type(type) + .receiver(reviewer) + .message(null) + .build(); + } } diff --git a/src/main/java/clap/server/domain/model/task/Attachment.java b/src/main/java/clap/server/domain/model/task/Attachment.java index db733936..1646eada 100644 --- a/src/main/java/clap/server/domain/model/task/Attachment.java +++ b/src/main/java/clap/server/domain/model/task/Attachment.java @@ -1,15 +1,12 @@ package clap.server.domain.model.task; -import clap.server.adapter.inbound.web.dto.task.AttachmentRequest; import clap.server.domain.model.common.BaseTime; import lombok.AccessLevel; +import lombok.Builder; import lombok.Getter; import lombok.NoArgsConstructor; import lombok.experimental.SuperBuilder; -import java.util.List; -import java.util.stream.Collectors; - @Getter @SuperBuilder @NoArgsConstructor(access = AccessLevel.PROTECTED) @@ -21,24 +18,29 @@ public class Attachment extends BaseTime { private String fileUrl; private String fileSize; - public static List createAttachments(Task task, List fileUrls) { - return fileUrls.stream() - .map(fileUrl -> Attachment.builder() - .task(task) - .fileUrl(fileUrl) - .originalName("파일 이름") - .fileSize("16MB") //TODO: 하드코딩 제거 - .build()) - .collect(Collectors.toList()); + @Builder + public Attachment(Task task, Comment comment, String originalName, String fileUrl, String fileSize) { + this.task = task; + this.comment = comment; + this.originalName = originalName; + this.fileUrl = fileUrl; + this.fileSize = fileSize; + } + + public static Attachment createAttachment(Task task, String originalName, String fileUrl, long fileSize) { + return Attachment.builder() + .task(task) + .comment(null) + .originalName(originalName) + .fileUrl(fileUrl) + .fileSize(formatFileSize(fileSize)) + .build(); + } + + public static String formatFileSize(long size) { + if (size < 1024) return size + " B"; + int z = (63 - Long.numberOfLeadingZeros(size)) / 10; + return String.format("%.1f %sB", (double) size / (1L << (z * 10)), " KMGTPE".charAt(z)); } - public static List updateAttachments(Task task, List attachmentRequests) { - return attachmentRequests.stream() - .map(request -> Attachment.builder() - .task(task) - .fileUrl(request.fileUrl()) - .originalName("수정된 파일 이름") - .fileSize("17MB") - .build()) - .collect(Collectors.toList()); - }} +} diff --git a/src/main/java/clap/server/domain/model/task/FilePath.java b/src/main/java/clap/server/domain/model/task/FilePath.java new file mode 100644 index 00000000..6786b741 --- /dev/null +++ b/src/main/java/clap/server/domain/model/task/FilePath.java @@ -0,0 +1,15 @@ +package clap.server.domain.model.task; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum FilePath { + TASK_IMAGE("task/image"), + TASK_DOCUMENT("task/docs"), + MEMBER_IMAGE("member/image"), + ; + private final String path; + +} diff --git a/src/main/java/clap/server/exception/ExceptionAdvice.java b/src/main/java/clap/server/exception/ExceptionAdvice.java index bf29d87a..1a9c0e78 100644 --- a/src/main/java/clap/server/exception/ExceptionAdvice.java +++ b/src/main/java/clap/server/exception/ExceptionAdvice.java @@ -3,7 +3,6 @@ import clap.server.exception.code.AuthErrorCode; import clap.server.exception.code.BaseErrorCode; import clap.server.exception.code.CommonErrorCode; -import clap.server.exception.code.StatisticsErrorCode; import jakarta.servlet.http.HttpServletRequest; import jakarta.validation.ConstraintViolation; import jakarta.validation.ConstraintViolationException; @@ -171,16 +170,4 @@ public ResponseEntity handleAccessDeniedException(AccessDeniedException AuthErrorCode.FORBIDDEN.getMessage() ); } - - @ExceptionHandler(StatisticsException.class) - public ResponseEntity handleAccessDeniedException(StatisticsException e, WebRequest request) { - return handleExceptionInternalFalse( - e, - StatisticsErrorCode.STATISTICS_BAD_REQUEST, - HttpHeaders.EMPTY, - HttpStatus.BAD_REQUEST, - request, - StatisticsErrorCode.STATISTICS_BAD_REQUEST.getMessage() - ); - } } diff --git a/src/main/java/clap/server/exception/S3Exception.java b/src/main/java/clap/server/exception/S3Exception.java new file mode 100644 index 00000000..45416cb8 --- /dev/null +++ b/src/main/java/clap/server/exception/S3Exception.java @@ -0,0 +1,13 @@ +package clap.server.exception; + +import clap.server.exception.code.BaseErrorCode; + +public class S3Exception extends BaseException { + public S3Exception(BaseErrorCode code) { + super(code); + } + + public BaseErrorCode getErrorCode() { + return (BaseErrorCode)super.getCode(); + } +} \ No newline at end of file diff --git a/src/main/java/clap/server/exception/code/S3Errorcode.java b/src/main/java/clap/server/exception/code/S3Errorcode.java new file mode 100644 index 00000000..30fc1cf6 --- /dev/null +++ b/src/main/java/clap/server/exception/code/S3Errorcode.java @@ -0,0 +1,15 @@ +package clap.server.exception.code; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; + +@Getter +@RequiredArgsConstructor +public enum S3Errorcode implements BaseErrorCode { + FILE_UPLOAD_REQUEST_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "TASK_004", "파일 업로드에 실패하였습니다."); + + private final HttpStatus httpStatus; + private final String customCode; + private final String message; +} diff --git a/src/main/java/clap/server/exception/code/TaskErrorCode.java b/src/main/java/clap/server/exception/code/TaskErrorCode.java index 189d1d61..4f6c8c64 100644 --- a/src/main/java/clap/server/exception/code/TaskErrorCode.java +++ b/src/main/java/clap/server/exception/code/TaskErrorCode.java @@ -9,8 +9,9 @@ public enum TaskErrorCode implements BaseErrorCode { TASK_NOT_FOUND(HttpStatus.NOT_FOUND, "TASK_001", "작업을 찾을 수 없습니다."), CATEGORY_NOT_FOUND(HttpStatus.NOT_FOUND, "TASK_002", "카테고리를 찾을 수 없습니다."), - TASK_STATUS_MISMATCH(HttpStatus.BAD_REQUEST, "TASK_003", "작업 상태가 일치하지 않습니다."); - ; + TASK_STATUS_MISMATCH(HttpStatus.BAD_REQUEST, "TASK_003", "작업 상태가 일치하지 않습니다."), + TASK_ATTACHMENT_NOT_FOUND(HttpStatus.NOT_FOUND, "TASK_004", "첨부파일을 찾을 수 없습니다."); + private final HttpStatus httpStatus; private final String customCode; diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index d5fbaff7..f4527cd4 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -8,6 +8,7 @@ spring: - redis.yml - auth.yml - elasticsearch.yml + - s3.yml application: name: taskflow web.resources.add-mappings: false diff --git a/src/main/resources/s3.yml b/src/main/resources/s3.yml new file mode 100644 index 00000000..d04485c8 --- /dev/null +++ b/src/main/resources/s3.yml @@ -0,0 +1,9 @@ +cloud: + kakao: + project-id: ${KAKAO_PROJECT_ID} + region: ${KAKAO_REGION} + object-storage: + endpoint: ${KAKAO_OBJECT_STORAGE_ENDPOINT} + access-key: ${KAKAO_OBJECT_STORAGE_ACCESS_KEY} + secret-key: ${KAKAO_OBJECT_STORAGE_SECRET_KEY} + bucketName: ${KAKAO_OBJECT_STORAGE_BUCKET_NAME} \ No newline at end of file