diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 0000000..b8e3de2 --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,44 @@ +name: Deploy to EC2 + +on: + push: + branches: [ dev ] # dev 브랜치에 푸시될 때만 실행 + +jobs: + deploy: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v3 + + - name: Set up JDK + uses: actions/setup-java@v3 + with: + java-version: '21' + distribution: 'temurin' + + - name: Build with Gradle + run: | + chmod +x ./gradlew + ./gradlew build -x test + + - name: Deploy to EC2 + uses: appleboy/scp-action@master + with: + host: ${{ secrets.EC2_HOST }} + username: ${{ secrets.EC2_USERNAME }} + key: ${{ secrets.EC2_SSH_KEY }} + source: "build/libs/*.jar" + target: "/home/ubuntu/deploy" + strip_components: 1 + + - name: Execute deploy script on EC2 + uses: appleboy/ssh-action@master + with: + host: ${{ secrets.EC2_HOST }} + username: ${{ secrets.EC2_USERNAME }} + key: ${{ secrets.EC2_SSH_KEY }} + script: | + cd /home/ubuntu/deploy + chmod +x deploy.sh + ./deploy.sh \ No newline at end of file diff --git a/build.gradle b/build.gradle index 10e378f..c923e3f 100644 --- a/build.gradle +++ b/build.gradle @@ -32,6 +32,11 @@ dependencies { implementation 'io.github.cdimascio:java-dotenv:5.2.2' implementation 'com.fasterxml.jackson.core:jackson-databind' implementation 'org.springframework.boot:spring-boot-starter-oauth2-client' + implementation 'com.fasterxml.jackson.datatype:jackson-datatype-jsr310' + + + implementation 'software.amazon.awssdk:s3:2.20.140' + implementation 'software.amazon.awssdk:netty-nio-client:2.20.140' implementation 'io.jsonwebtoken:jjwt-api:0.11.5' implementation 'io.jsonwebtoken:jjwt-impl:0.11.5' diff --git a/src/main/java/pawparazzi/back/BackApplication.java b/src/main/java/pawparazzi/back/BackApplication.java index 7c2942b..785540f 100644 --- a/src/main/java/pawparazzi/back/BackApplication.java +++ b/src/main/java/pawparazzi/back/BackApplication.java @@ -3,22 +3,20 @@ import io.github.cdimascio.dotenv.Dotenv; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.scheduling.annotation.EnableAsync; @SpringBootApplication +@EnableAsync public class BackApplication { public static void main(String[] args) { - // .env 파일 로드 - Dotenv dotenv = Dotenv.load(); - System.setProperty("PAWPARAZZI_DB_URL", dotenv.get("PAWPARAZZI_DB_URL")); - System.setProperty("PAWPARAZZI_DB_USERNAME", dotenv.get("PAWPARAZZI_DB_USERNAME")); - System.setProperty("PAWPARAZZI_DB_PASSWORD", dotenv.get("PAWPARAZZI_DB_PASSWORD")); - System.setProperty("PAWPARAZZI_MONGO_URI", dotenv.get("PAWPARAZZI_MONGO_URI")); - System.setProperty("KAKAO_CLIENT_ID", dotenv.get("KAKAO_CLIENT_ID")); - System.setProperty("KAKAO_CLIENT_SECRET", dotenv.get("KAKAO_CLIENT_SECRET")); - System.setProperty("KAKAO_REDIRECT_URI", dotenv.get("KAKAO_REDIRECT_URI")); - System.setProperty("JWT_SECRET", dotenv.get("JWT_SECRET")); - System.setProperty("JWT_EXPIRATION", dotenv.get("JWT_EXPIRATION")); + Dotenv dotenv = Dotenv.configure().ignoreIfMissing().load(); + + dotenv.entries().forEach(entry -> { + if (System.getProperty(entry.getKey()) == null) { + System.setProperty(entry.getKey(), entry.getValue()); + } + }); SpringApplication.run(BackApplication.class, args); } -} +} \ No newline at end of file diff --git a/src/main/java/pawparazzi/back/S3/S3AsyncConfig.java b/src/main/java/pawparazzi/back/S3/S3AsyncConfig.java new file mode 100644 index 0000000..86dccd1 --- /dev/null +++ b/src/main/java/pawparazzi/back/S3/S3AsyncConfig.java @@ -0,0 +1,38 @@ +package pawparazzi.back.S3; + +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.S3AsyncClient; +import software.amazon.awssdk.http.nio.netty.NettyNioAsyncHttpClient; + +import java.time.Duration; + +@Configuration +public class S3AsyncConfig { + + @Value("${aws.access-key}") + private String accessKey; + + @Value("${aws.secret-key}") + private String secretKey; + + @Value("${aws.region}") + private String region; + + @Bean + public S3AsyncClient s3AsyncClient() { + return S3AsyncClient.builder() + .region(Region.of(region)) + .credentialsProvider(StaticCredentialsProvider.create( + AwsBasicCredentials.create(accessKey, secretKey) + )) + .httpClientBuilder(NettyNioAsyncHttpClient.builder() + .connectionTimeout(Duration.ofSeconds(10)) + ) + .build(); + } +} \ No newline at end of file diff --git a/src/main/java/pawparazzi/back/S3/S3UploadUtil.java b/src/main/java/pawparazzi/back/S3/S3UploadUtil.java new file mode 100644 index 0000000..b347cea --- /dev/null +++ b/src/main/java/pawparazzi/back/S3/S3UploadUtil.java @@ -0,0 +1,35 @@ +package pawparazzi.back.S3; + +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Component; +import org.springframework.web.multipart.MultipartFile; +import pawparazzi.back.S3.service.S3AsyncService; + +import java.io.IOException; +import java.util.concurrent.CompletableFuture; + +@Component +public class S3UploadUtil { + + private final S3AsyncService s3AsyncService; + + public S3UploadUtil(S3AsyncService s3AsyncService) { + this.s3AsyncService = s3AsyncService; + } + + @Async + public CompletableFuture uploadImageAsync(MultipartFile file, String pathPrefix, String defaultImageUrl) { + if (file == null || file.isEmpty()) { + return CompletableFuture.completedFuture(defaultImageUrl); + } + + try { + String fileName = pathPrefix + "_" + System.currentTimeMillis(); + return s3AsyncService.uploadFile(fileName, file.getBytes(), file.getContentType()); + } catch (IOException e) { + CompletableFuture failedFuture = new CompletableFuture<>(); + failedFuture.completeExceptionally(new RuntimeException("파일 업로드 실패: " + e.getMessage())); + return failedFuture; + } + } +} \ No newline at end of file diff --git a/src/main/java/pawparazzi/back/S3/controller/ImageAsyncController.java b/src/main/java/pawparazzi/back/S3/controller/ImageAsyncController.java new file mode 100644 index 0000000..75a2fe3 --- /dev/null +++ b/src/main/java/pawparazzi/back/S3/controller/ImageAsyncController.java @@ -0,0 +1,47 @@ +package pawparazzi.back.S3.controller; + +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; +import pawparazzi.back.S3.service.S3AsyncService; + +import java.io.IOException; +import java.util.concurrent.CompletableFuture; + +@RestController +@RequestMapping("/api/async/images") +public class ImageAsyncController { + + private final S3AsyncService s3AsyncService; + + public ImageAsyncController(S3AsyncService s3AsyncService) { + this.s3AsyncService = s3AsyncService; + } + + /** + * 비동기적으로 이미지 업로드 + */ + @PostMapping("/upload") + public CompletableFuture> uploadImage(@RequestParam("file") MultipartFile file) { + try { + String contentType = file.getContentType(); + byte[] fileBytes = file.getBytes(); + String fileName = System.currentTimeMillis() + "_" + file.getOriginalFilename(); + + return s3AsyncService.uploadFile(fileName, fileBytes, contentType) + .thenApply(url -> ResponseEntity.ok("File uploaded successfully: " + url)); + } catch (IOException e) { + return CompletableFuture.completedFuture(ResponseEntity.badRequest().body("File upload failed: " + e.getMessage())); + } + } + + /** + * 비동기적으로 이미지 삭제 + */ + @DeleteMapping("/delete") + public CompletableFuture> deleteImage(@RequestParam("fileName") String fileName) { + return s3AsyncService.deleteFile(fileName) + .thenApply(voidRes -> ResponseEntity.ok("File deleted successfully: " + fileName)) + .exceptionally(ex -> ResponseEntity.badRequest().body("File delete failed: " + ex.getMessage())); + } +} \ No newline at end of file diff --git a/src/main/java/pawparazzi/back/S3/service/S3AsyncService.java b/src/main/java/pawparazzi/back/S3/service/S3AsyncService.java new file mode 100644 index 0000000..7edde09 --- /dev/null +++ b/src/main/java/pawparazzi/back/S3/service/S3AsyncService.java @@ -0,0 +1,133 @@ +package pawparazzi.back.S3.service; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Service; +import software.amazon.awssdk.core.async.AsyncRequestBody; +import software.amazon.awssdk.services.s3.S3AsyncClient; +import software.amazon.awssdk.services.s3.model.*; + +import java.nio.ByteBuffer; +import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.stream.Collectors; + +@Service +public class S3AsyncService { + private final S3AsyncClient s3AsyncClient; + + @Value("${aws.s3-bucket}") + private String bucketName; + + public S3AsyncService(S3AsyncClient s3AsyncClient) { + this.s3AsyncClient = s3AsyncClient; + } + + /** + * S3에 비동기적으로 파일 업로드 + */ + @Async + public CompletableFuture uploadFile(String fileName, byte[] fileData, String contentType) { + PutObjectRequest putObjectRequest = PutObjectRequest.builder() + .bucket(bucketName) + .key(fileName) + .contentType(contentType) + .build(); + + return s3AsyncClient.putObject(putObjectRequest, AsyncRequestBody.fromByteBuffer(ByteBuffer.wrap(fileData))) + .thenApply(response -> { + if (response.sdkHttpResponse().isSuccessful()) { + return "https://" + bucketName + ".s3.amazonaws.com/" + fileName; + } else { + throw new RuntimeException("S3 업로드 실패: " + response.sdkHttpResponse().statusCode()); + } + }); + } + + /** + * S3에서 비동기적으로 파일 삭제 + */ + public CompletableFuture deleteFile(String key) { + return s3AsyncClient.listObjectVersions(ListObjectVersionsRequest.builder() + .bucket(bucketName) + .prefix(key) + .build()) + .thenCompose(response -> { + List objectsToDelete = response.versions().stream() + .map(version -> ObjectIdentifier.builder() + .key(version.key()) + .versionId(version.versionId()) + .build()) + .collect(Collectors.toList()); + + if (objectsToDelete.isEmpty()) { + System.out.println("삭제할 버전 없음: " + key); + return CompletableFuture.completedFuture(null); + } + + DeleteObjectsRequest deleteObjectsRequest = DeleteObjectsRequest.builder() + .bucket(bucketName) + .delete(Delete.builder().objects(objectsToDelete).build()) + .build(); + + return s3AsyncClient.deleteObjects(deleteObjectsRequest) + .thenRun(() -> System.out.println("S3 삭제 성공 (버전 포함): " + key)) + .exceptionally(ex -> { + System.err.println("S3 삭제 실패: " + ex.getMessage()); + return null; + }); + }); + } + + /** + * 기존 프로필 이미지 삭제 후 새 이미지 업로드 + */ + public CompletableFuture updateProfileImage(String existingImageUrl, String newFileName, byte[] newFileData, String contentType) { + if (existingImageUrl != null && !existingImageUrl.isBlank()) { + String oldFileName = extractFileName(existingImageUrl); + deleteFile(oldFileName).join(); + } + return uploadFile(newFileName, newFileData, contentType); + } + + /** + * S3 URL에서 파일 이름을 추출하는 메서드 + */ + public String extractFileName(String imageUrl) { + return imageUrl.substring(imageUrl.lastIndexOf("/") + 1); + } + + + public List listFilesInFolder(String folderPath) { + ListObjectsV2Request listRequest = ListObjectsV2Request.builder() + .bucket(bucketName) + .prefix(folderPath) + .build(); + + ListObjectsV2Response listResponse = s3AsyncClient.listObjectsV2(listRequest).join(); + + return listResponse.contents().stream() + .map(s3Object -> s3Object.key()) + .collect(Collectors.toList()); + } + + public CompletableFuture deleteFiles(List fileKeys) { + if (fileKeys.isEmpty()) { + return CompletableFuture.completedFuture(null); + } + + DeleteObjectsRequest deleteRequest = DeleteObjectsRequest.builder() + .bucket(bucketName) + .delete(Delete.builder() + .objects(fileKeys.stream() + .map(key -> ObjectIdentifier.builder().key(key).build()) + .collect(Collectors.toList())) + .build()) + .build(); + + return s3AsyncClient.deleteObjects(deleteRequest) + .thenApply(response -> null); + } + + +} \ No newline at end of file diff --git a/src/main/java/pawparazzi/back/board/controller/BoardController.java b/src/main/java/pawparazzi/back/board/controller/BoardController.java index d4b1adc..22b8d89 100644 --- a/src/main/java/pawparazzi/back/board/controller/BoardController.java +++ b/src/main/java/pawparazzi/back/board/controller/BoardController.java @@ -1,8 +1,12 @@ package pawparazzi.back.board.controller; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; import lombok.RequiredArgsConstructor; +import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; import pawparazzi.back.board.dto.BoardCreateRequestDto; import pawparazzi.back.board.dto.BoardListResponseDto; import pawparazzi.back.board.dto.BoardDetailDto; @@ -19,17 +23,31 @@ public class BoardController { private final BoardService boardService; private final JwtUtil jwtUtil; + private final ObjectMapper objectMapper; /** - * 게시글 등록 + * 게시물 등록 */ - @PostMapping + @PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE) public ResponseEntity createBoard( @RequestHeader("Authorization") String token, - @RequestBody BoardCreateRequestDto requestDto) { + @RequestPart("userData") String userDataJson, + @RequestPart(value = "mediaFiles", required = false) List mediaFiles, + @RequestPart(value = "titleImage", required = false) MultipartFile titleImageFile, + @RequestPart(value = "titleContent", required = false) String titleContent) { Long memberId = jwtUtil.extractMemberId(token.replace("Bearer ", "")); - BoardDetailDto response = boardService.createBoard(requestDto, memberId); + + BoardCreateRequestDto requestDto; + try { + requestDto = objectMapper.readValue(userDataJson, BoardCreateRequestDto.class); + requestDto.setMediaFiles(mediaFiles); + requestDto.setTitleContent(titleContent); + } catch (JsonProcessingException e) { + return ResponseEntity.badRequest().body(null); + } + + BoardDetailDto response = boardService.createBoard(requestDto, memberId, titleImageFile); return ResponseEntity.ok(response); } @@ -54,15 +72,28 @@ public ResponseEntity> getBoardList() { /** * 게시물 수정 */ - @PutMapping("/{boardId}") + @PutMapping(value = "/{boardId}", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) public ResponseEntity updateBoard( @PathVariable Long boardId, @RequestHeader("Authorization") String token, - @RequestBody BoardUpdateRequestDto requestDto) { + @RequestPart("userData") String userDataJson, + @RequestPart(value = "mediaFiles", required = false) List mediaFiles, + @RequestPart(value = "titleImage", required = false) MultipartFile titleImageFile, + @RequestPart(value = "titleContent", required = false) String titleContent) { - Long memberId = jwtUtil.extractMemberId(token.replace("Bearer ", "")); - BoardDetailDto updatedBoard = boardService.updateBoard(boardId, memberId, requestDto); - return ResponseEntity.ok(updatedBoard); + try { + Long memberId = jwtUtil.extractMemberId(token.replace("Bearer ", "")); + + BoardUpdateRequestDto requestDto = objectMapper.readValue(userDataJson, BoardUpdateRequestDto.class); + + requestDto.setTitleContent(titleContent); + + BoardDetailDto updatedBoard = boardService.updateBoard(boardId, memberId, requestDto, mediaFiles, titleImageFile).join(); + + return ResponseEntity.ok(updatedBoard); + } catch (JsonProcessingException e) { + return ResponseEntity.badRequest().body(null); + } } /** @@ -78,12 +109,11 @@ public ResponseEntity> getBoardsByMember(@PathVariabl * 게시물 삭제 */ @DeleteMapping("/{boardId}") - public ResponseEntity deleteBoard( - @PathVariable Long boardId, - @RequestHeader("Authorization") String token) { + public ResponseEntity deleteBoard(@PathVariable Long boardId, @RequestHeader("Authorization") String token) { + Long userId = jwtUtil.extractMemberId(token.replace("Bearer ", "")); - Long memberId = jwtUtil.extractMemberId(token.replace("Bearer ", "")); - boardService.deleteBoard(boardId, memberId); - return ResponseEntity.ok("게시물이 삭제되었습니다."); + boardService.deleteBoard(boardId, userId).join(); + + return ResponseEntity.noContent().build(); } } \ No newline at end of file diff --git a/src/main/java/pawparazzi/back/board/dto/BoardCreateRequestDto.java b/src/main/java/pawparazzi/back/board/dto/BoardCreateRequestDto.java index 554ca7e..9fd1669 100644 --- a/src/main/java/pawparazzi/back/board/dto/BoardCreateRequestDto.java +++ b/src/main/java/pawparazzi/back/board/dto/BoardCreateRequestDto.java @@ -5,6 +5,7 @@ import lombok.Getter; import lombok.NoArgsConstructor; import lombok.Setter; +import org.springframework.web.multipart.MultipartFile; import pawparazzi.back.board.entity.BoardVisibility; import java.util.List; @@ -22,10 +23,12 @@ public class BoardCreateRequestDto { private List contents; - private String titleImage; - private String titleContent; + private MultipartFile titleImage; + + private List mediaFiles; + @Getter @Setter diff --git a/src/main/java/pawparazzi/back/board/dto/BoardUpdateRequestDto.java b/src/main/java/pawparazzi/back/board/dto/BoardUpdateRequestDto.java index b93a48f..e7ab024 100644 --- a/src/main/java/pawparazzi/back/board/dto/BoardUpdateRequestDto.java +++ b/src/main/java/pawparazzi/back/board/dto/BoardUpdateRequestDto.java @@ -22,8 +22,12 @@ public class BoardUpdateRequestDto { private String titleImage; + private String titleContent; + private List contents; + private List deleteMediaUrls; + @Getter @Setter diff --git a/src/main/java/pawparazzi/back/board/service/BoardService.java b/src/main/java/pawparazzi/back/board/service/BoardService.java index 83ce47b..e91b861 100644 --- a/src/main/java/pawparazzi/back/board/service/BoardService.java +++ b/src/main/java/pawparazzi/back/board/service/BoardService.java @@ -4,6 +4,8 @@ import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.multipart.MultipartFile; +import pawparazzi.back.S3.service.S3AsyncService; import pawparazzi.back.board.dto.BoardCreateRequestDto; import pawparazzi.back.board.dto.BoardListResponseDto; import pawparazzi.back.board.dto.BoardDetailDto; @@ -21,7 +23,10 @@ import pawparazzi.back.member.entity.Member; import pawparazzi.back.member.repository.MemberRepository; +import java.io.IOException; +import java.util.ArrayList; import java.util.List; +import java.util.concurrent.CompletableFuture; import java.util.stream.Collectors; @Service @@ -36,58 +41,100 @@ public class BoardService { private final CommentLikeRepository commentLikeRepository; private final ReplyRepository replyRepository; private final ReplyLikeRepository replyLikeRepository; + private final S3AsyncService s3AsyncService; + /** * 게시물 등록 */ @Transactional - public BoardDetailDto createBoard(BoardCreateRequestDto requestDto, Long userId) { + public BoardDetailDto createBoard(BoardCreateRequestDto requestDto, Long userId, MultipartFile titleImageFile) { Member member = memberRepository.findById(userId) .orElseThrow(() -> new IllegalArgumentException("사용자를 찾을 수 없습니다.")); - List contents = requestDto.getContents().stream() - .map(dto -> new BoardDocument.ContentDto(dto.getType(), dto.getValue())) - .collect(Collectors.toList()); - - String titleImage = requestDto.getTitleImage(); - if (titleImage == null || titleImage.isBlank()) { - titleImage = contents.stream() - .filter(c -> "image".equals(c.getType())) - .map(BoardDocument.ContentDto::getValue) - .findFirst() - .orElse(null); - } - - String firstText = contents.stream() - .filter(c -> "text".equals(c.getType())) - .map(BoardDocument.ContentDto::getValue) - .findFirst() - .orElse(null); - if (requestDto.getVisibility() == null) { throw new IllegalArgumentException("게시물 공개 설정은 필수 입력값입니다."); } - // MongoDB에 게시물 저장 - BoardDocument boardDocument = new BoardDocument(null, requestDto.getTitle(), titleImage, firstText, contents); - boardMongoRepository.save(boardDocument); + // MongoDB에 게시물 저장 (임시 저장) + BoardDocument boardDocument = new BoardDocument(null, requestDto.getTitle(), null, requestDto.getTitleContent(), new ArrayList<>()); + boardDocument = boardMongoRepository.save(boardDocument); // MySQL에 게시물 저장 Board board = new Board(member, boardDocument.getId(), requestDto.getVisibility()); boardRepository.save(board); - // MongoDB에 MySQL ID 업데이트 + // 컨텐츠 변환 + List contents = requestDto.getContents().stream() + .map(dto -> new BoardDocument.ContentDto(dto.getType(), dto.getValue())) + .collect(Collectors.toList()); + + // S3 비동기 업로드 + CompletableFuture> uploadFuture = uploadFilesToS3(requestDto.getMediaFiles(), board.getId()); + + // S3 업로드된 이미지 URL을 MongoDB 컨텐츠에 추가 + List uploadedUrls = uploadFuture.join(); + uploadedUrls.forEach(url -> contents.add(new BoardDocument.ContentDto("File", url))); + + String titleImage = getTitleImageUrl(titleImageFile, uploadedUrls); + boardDocument.setMysqlId(board.getId()); + boardDocument.setContents(contents); + boardDocument.setTitleImage(titleImage); boardMongoRepository.save(boardDocument); return convertToBoardDetailDto(board, boardDocument); } + /** + * 비동기 방식으로 S3에 파일 업로드 + */ + private CompletableFuture> uploadFilesToS3(List mediaFiles, Long boardId) { + if (mediaFiles == null || mediaFiles.isEmpty()) { + return CompletableFuture.completedFuture(new ArrayList<>()); + } + + List> uploadFutures = mediaFiles.stream() + .map(file -> { + String fileName = "board_images/" + boardId + "/" + System.currentTimeMillis() + "_" + file.getOriginalFilename(); + try { + return s3AsyncService.uploadFile(fileName, file.getBytes(), file.getContentType()); + } catch (IOException e) { + return CompletableFuture.failedFuture(new RuntimeException("파일 업로드 실패: " + e.getMessage())); + } + }) + .collect(Collectors.toList()); + + return CompletableFuture.allOf(uploadFutures.toArray(new CompletableFuture[0])) + .thenApply(v -> uploadFutures.stream() + .map(CompletableFuture::join) + .toList() + ); + } + + /** + * titleImage + */ + private String getTitleImageUrl(MultipartFile titleImageFile, List uploadedUrls) { + if (titleImageFile == null || titleImageFile.isEmpty()) { + return null; + } + String fileName = titleImageFile.getOriginalFilename(); + return uploadedUrls.stream() + .filter(url -> url.contains(fileName)) + .findFirst() + .orElse(null); + } + + /** * 게시물 수정 */ @Transactional - public BoardDetailDto updateBoard(Long boardId, Long userId, BoardUpdateRequestDto requestDto) { + public CompletableFuture updateBoard(Long boardId, Long userId, + BoardUpdateRequestDto requestDto, + List mediaFiles, + MultipartFile titleImageFile) { Board board = boardRepository.findById(boardId) .orElseThrow(() -> new EntityNotFoundException("게시물을 찾을 수 없습니다.")); @@ -98,37 +145,53 @@ public BoardDetailDto updateBoard(Long boardId, Long userId, BoardUpdateRequestD BoardDocument boardDocument = boardMongoRepository.findByMysqlId(boardId) .orElseThrow(() -> new EntityNotFoundException("MongoDB에서 해당 게시글을 찾을 수 없습니다.")); - if (requestDto.getTitle() != null && !requestDto.getTitle().isBlank()) { - boardDocument.setTitle(requestDto.getTitle()); - } - - if (requestDto.getTitleImage() != null && !requestDto.getTitleImage().isBlank()) { - boardDocument.setTitleImage(requestDto.getTitleImage()); - } - - if (requestDto.getContents() != null && !requestDto.getContents().isEmpty()) { - List updatedContents = requestDto.getContents().stream() - .map(dto -> new BoardDocument.ContentDto(dto.getType(), dto.getValue())) - .collect(Collectors.toList()); - boardDocument.setContents(updatedContents); - - String firstText = updatedContents.stream() - .filter(c -> "text".equals(c.getType())) - .map(BoardDocument.ContentDto::getValue) - .findFirst() - .orElse(null); - boardDocument.setTitleContent(firstText); - } - - if (requestDto.getVisibility() != null) { - board.setVisibility(requestDto.getVisibility()); - } - - // MongoDB , MySQL 업데이트 - boardMongoRepository.save(boardDocument); - boardRepository.save(board); - - return convertToBoardDetailDto(board, boardDocument); + String folderPath = "board_images/" + boardId + "/"; + List existingFileKeys = s3AsyncService.listFilesInFolder(folderPath); + + CompletableFuture deleteFuture = existingFileKeys.isEmpty() + ? CompletableFuture.completedFuture(null) + : s3AsyncService.deleteFiles(existingFileKeys) + .exceptionally(ex -> { + System.err.println("S3 기존 이미지 삭제 실패: " + ex.getMessage()); + return null; + }); + + boardDocument.setContents( + boardDocument.getContents().stream() + .filter(content -> !"image".equals(content.getType())) + .toList() + ); + + CompletableFuture> uploadFuture = (mediaFiles == null || mediaFiles.isEmpty()) + ? CompletableFuture.completedFuture(new ArrayList<>()) + : uploadFilesToS3(mediaFiles, board.getId()); + + return CompletableFuture.allOf(deleteFuture, uploadFuture) + .thenCompose(ignored -> uploadFuture.thenApply(uploadedUrls -> { + List updatedContents = new ArrayList<>(); + if (requestDto.getContents() != null && !requestDto.getContents().isEmpty()) { + updatedContents.addAll(requestDto.getContents().stream() + .map(dto -> new BoardDocument.ContentDto(dto.getType(), dto.getValue())) + .toList()); + } + uploadedUrls.forEach(url -> updatedContents.add(new BoardDocument.ContentDto("image", url))); + + boardDocument.setContents(updatedContents); + + String titleImage = getTitleImageUrl(titleImageFile, uploadedUrls); + boardDocument.setTitleImage(titleImage); + + boardDocument.setTitleContent(requestDto.getTitleContent()); + + if (requestDto.getVisibility() != null) { + board.setVisibility(requestDto.getVisibility()); + } + + boardMongoRepository.save(boardDocument); + boardRepository.save(board); + + return convertToBoardDetailDto(board, boardDocument); + })); } /** @@ -230,7 +293,6 @@ private BoardDetailDto convertToBoardDetailDto(Board board, BoardDocument boardD dto.setAuthor(authorDto); } - // MongoDB에서 가져온 contents 데이터를 변환 후 저장 dto.setContents(boardDocument.getContents().stream() .map(content -> new BoardDetailDto.ContentDto(content.getType(), content.getValue())) .collect(Collectors.toList())); @@ -243,14 +305,25 @@ private BoardDetailDto convertToBoardDetailDto(Board board, BoardDocument boardD * 게시물 삭제 */ @Transactional - public void deleteBoard(Long boardId, Long userId) { + public CompletableFuture deleteBoard(Long boardId, Long userId) { Board board = boardRepository.findById(boardId) .orElseThrow(() -> new IllegalArgumentException("해당 게시물을 찾을 수 없습니다.")); - if (!board.getAuthorId().equals(userId)) { // getAuthorId() 사용 가능 + if (!board.getAuthorId().equals(userId)) { throw new IllegalArgumentException("본인이 작성한 게시물만 삭제할 수 있습니다."); } + String folderPath = "board_images/" + boardId + "/"; + List fileKeys = s3AsyncService.listFilesInFolder(folderPath); + + CompletableFuture deleteS3Future = fileKeys.isEmpty() + ? CompletableFuture.completedFuture(null) + : s3AsyncService.deleteFiles(fileKeys) + .exceptionally(ex -> { + System.err.println("게시물 이미지 삭제 실패: " + ex.getMessage()); + return null; + }); + replyRepository.findByBoardId(boardId).forEach(reply -> { replyLikeRepository.deleteByReplyId(reply.getId()); }); @@ -259,8 +332,9 @@ public void deleteBoard(Long boardId, Long userId) { commentLikeRepository.deleteByBoardId(boardId); commentRepository.deleteByBoardId(boardId); likeRepository.deleteByBoardId(boardId); - boardMongoRepository.deleteByMysqlId(board.getId()); boardRepository.delete(board); + + return deleteS3Future; } } \ No newline at end of file diff --git a/src/main/java/pawparazzi/back/config/JacksonConfig.java b/src/main/java/pawparazzi/back/config/JacksonConfig.java new file mode 100644 index 0000000..916fb49 --- /dev/null +++ b/src/main/java/pawparazzi/back/config/JacksonConfig.java @@ -0,0 +1,18 @@ +package pawparazzi.back.config; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class JacksonConfig { + + @Bean + public ObjectMapper objectMapper() { + return new ObjectMapper() + .registerModule(new JavaTimeModule()) + .disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS); + } +} \ No newline at end of file diff --git a/src/main/java/pawparazzi/back/follow/controller/FollowController.java b/src/main/java/pawparazzi/back/follow/controller/FollowController.java index d3de04c..4dd2a33 100644 --- a/src/main/java/pawparazzi/back/follow/controller/FollowController.java +++ b/src/main/java/pawparazzi/back/follow/controller/FollowController.java @@ -6,6 +6,7 @@ import pawparazzi.back.follow.dto.FollowResponseDto; import pawparazzi.back.follow.dto.FollowerResponseDto; import pawparazzi.back.follow.dto.FollowingResponseDto; +import pawparazzi.back.follow.dto.UnfollowResponseDto; import pawparazzi.back.follow.service.FollowService; import java.util.List; @@ -25,13 +26,12 @@ public ResponseEntity follow( return ResponseEntity.ok(response); } - @DeleteMapping("/{targetId}") - public ResponseEntity unfollow( + public ResponseEntity unfollow( @PathVariable Long targetId, @RequestHeader("Authorization") String token){ - followService.unfollow(targetId, token); - return ResponseEntity.ok("팔로우 취소되었습니다."); + UnfollowResponseDto response = followService.unfollow(targetId, token); + return ResponseEntity.ok(response); } @GetMapping("/followers/{targetId}") diff --git a/src/main/java/pawparazzi/back/follow/dto/FollowRequestDto.java b/src/main/java/pawparazzi/back/follow/dto/FollowRequestDto.java deleted file mode 100644 index 46eae42..0000000 --- a/src/main/java/pawparazzi/back/follow/dto/FollowRequestDto.java +++ /dev/null @@ -1,12 +0,0 @@ -package pawparazzi.back.follow.dto; - - -import lombok.Getter; -import lombok.NoArgsConstructor; - -@Getter -@NoArgsConstructor -public class FollowRequestDto { - private Long followerId; - private Long followingId; -} diff --git a/src/main/java/pawparazzi/back/follow/dto/UnfollowResponseDto.java b/src/main/java/pawparazzi/back/follow/dto/UnfollowResponseDto.java new file mode 100644 index 0000000..d23c8d8 --- /dev/null +++ b/src/main/java/pawparazzi/back/follow/dto/UnfollowResponseDto.java @@ -0,0 +1,22 @@ +package pawparazzi.back.follow.dto; + +import lombok.Getter; +import lombok.Setter; + +/** + * 언팔로우 시 반환하는 DTO + * unfollow 메서드에서 사용 + */ +@Getter +@Setter +public class UnfollowResponseDto { + private Long followerId; + private Long followingId; + private String followerNickName; + private String followingNickName; + private String followerProfileImageUrl; + private String followingProfileImageUrl; + private int followerCount; + private int followingCount; + private boolean followedStatus; +} diff --git a/src/main/java/pawparazzi/back/follow/service/FollowService.java b/src/main/java/pawparazzi/back/follow/service/FollowService.java index c083cda..d0540d2 100644 --- a/src/main/java/pawparazzi/back/follow/service/FollowService.java +++ b/src/main/java/pawparazzi/back/follow/service/FollowService.java @@ -7,6 +7,7 @@ import pawparazzi.back.follow.dto.FollowResponseDto; import pawparazzi.back.follow.dto.FollowerResponseDto; import pawparazzi.back.follow.dto.FollowingResponseDto; +import pawparazzi.back.follow.dto.UnfollowResponseDto; import pawparazzi.back.follow.entity.Follow; import pawparazzi.back.follow.repository.FollowRepository; import pawparazzi.back.member.entity.Member; @@ -43,8 +44,13 @@ public FollowResponseDto follow(Long targetId, String token) { Follow follow = new Follow(member, following); followRepository.save(follow); - int followerCount = followRepository.countByFollower(member); - int followingCount = followRepository.countByFollowing(member); + /** + * A(팔로우 하는 사람) ---> B(팔로우 당하는 사람) + */ + //B의 팔로워 수 + int followerCount = followRepository.countByFollowing(following); + //A의 팔로잉 수 + int followingCount = followRepository.countByFollower(member); FollowResponseDto dto = getFollowResponseDto(member, following, followerCount, followingCount); dto.setFollowedStatus(true); @@ -52,7 +58,7 @@ public FollowResponseDto follow(Long targetId, String token) { } @Transactional - public void unfollow(Long targetId, String token) { + public UnfollowResponseDto unfollow(Long targetId, String token) { Long userId = jwtUtil.extractMemberId(token.replace("Bearer ", "")); Member member = memberRepository.findById(userId) .orElseThrow(() -> new IllegalArgumentException("사용자를 찾을 수 없습니다.")); @@ -62,8 +68,18 @@ public void unfollow(Long targetId, String token) { //추후 수정 필요 (특정 회원 프로필 정보에서 체크하여 메서드 활성화 및 비활성화) Follow follow = followRepository.findByFollowerAndFollowing(member, following) .orElseThrow(() -> new IllegalArgumentException("해당 사용자를 팔로우하고 있지 않습니다.")); - followRepository.delete(follow); + /** + * A(팔로우 하는 사람/member) ---> B(팔로우 당하는 사람/following) + */ + //B의 팔로워 수 + int followerCount = followRepository.countByFollowing(following); + //A의 팔로잉 수 + int followingCount = followRepository.countByFollower(member); + + UnfollowResponseDto dto = getUnfollowResponseDto(member, following, followerCount, followingCount); + dto.setFollowedStatus(false); + return dto; } @Transactional(readOnly = true) @@ -116,4 +132,18 @@ private static FollowResponseDto getFollowResponseDto(Member member, Member foll return responseDto; } + @NotNull + private static UnfollowResponseDto getUnfollowResponseDto(Member member, Member following, int followerCount, int followingCount) { + UnfollowResponseDto responseDto = new UnfollowResponseDto(); + responseDto.setFollowerId(member.getId()); + responseDto.setFollowingId(following.getId()); + responseDto.setFollowerNickName(member.getNickName()); + responseDto.setFollowingNickName(following.getNickName()); + responseDto.setFollowerProfileImageUrl(member.getProfileImageUrl()); + responseDto.setFollowingProfileImageUrl(following.getProfileImageUrl()); + responseDto.setFollowerCount(followerCount); + responseDto.setFollowingCount(followingCount); + return responseDto; + } + } diff --git a/src/main/java/pawparazzi/back/member/controller/AuthController.java b/src/main/java/pawparazzi/back/member/controller/AuthController.java index 165a228..866b98e 100644 --- a/src/main/java/pawparazzi/back/member/controller/AuthController.java +++ b/src/main/java/pawparazzi/back/member/controller/AuthController.java @@ -57,7 +57,11 @@ public ResponseEntity kakaoLogin(@RequestParam String code) { Long memberId = memberService.handleKakaoLogin(kakaoUser); String jwtToken = jwtUtil.generateIdToken(memberId); - return ResponseEntity.ok(new JwtResponseDto(jwtToken)); + String frontendRedirectUrl = "http://localhost:8082/auth/success?token=" + jwtToken; + + HttpHeaders headers = new HttpHeaders(); + headers.setLocation(URI.create(frontendRedirectUrl)); + return ResponseEntity.status(302).headers(headers).build(); } catch (Exception e) { log.error("카카오 로그인 처리 중 오류 발생: {}", e.getMessage()); diff --git a/src/main/java/pawparazzi/back/member/controller/MemberController.java b/src/main/java/pawparazzi/back/member/controller/MemberController.java index ad8086b..c338e1a 100644 --- a/src/main/java/pawparazzi/back/member/controller/MemberController.java +++ b/src/main/java/pawparazzi/back/member/controller/MemberController.java @@ -1,9 +1,15 @@ package pawparazzi.back.member.controller; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.servlet.http.HttpServletRequest; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; +import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; +import pawparazzi.back.S3.service.S3AsyncService; import pawparazzi.back.member.dto.request.LoginRequestDto; import pawparazzi.back.member.dto.request.SignUpRequestDto; import pawparazzi.back.member.dto.request.UpdateMemberRequestDto; @@ -13,8 +19,10 @@ import pawparazzi.back.member.service.MemberService; import pawparazzi.back.security.util.JwtUtil; +import java.io.IOException; import java.util.List; import java.util.Map; +import java.util.concurrent.CompletableFuture; @RestController @RequestMapping("/api/auth") @@ -23,14 +31,28 @@ public class MemberController { private final JwtUtil jwtUtil; private final MemberService memberService; + private final ObjectMapper objectMapper; + /** * 회원 가입 */ - @PostMapping("/signup") - public ResponseEntity registerUser(@Valid @RequestBody SignUpRequestDto request) { - memberService.registerUser(request); - return ResponseEntity.ok("회원가입 성공"); + @PostMapping(value = "/signup", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + public CompletableFuture> registerUser( + @RequestPart(value = "profileImage", required = false) MultipartFile profileImage, + @RequestPart("userData") String userDataJson) { + + // JSON 데이터를 DTO로 변환 + SignUpRequestDto request; + try { + request = objectMapper.readValue(userDataJson, SignUpRequestDto.class); + } catch (JsonProcessingException e) { + return CompletableFuture.completedFuture(ResponseEntity.badRequest().body("Invalid JSON format")); + } + + // 비동기 회원가입 처리 후 응답 반환 + return memberService.registerUser(request, profileImage) + .thenApply(unused -> ResponseEntity.ok("회원가입 성공")); } /** @@ -56,17 +78,27 @@ public ResponseEntity getCurrentUser(@RequestHeader("Authorization") Str /** * 사용자 정보 수정 */ - @PatchMapping("/me") - public ResponseEntity updateMember( + @PatchMapping(value = "/me", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + public CompletableFuture> updateMember( @RequestHeader("Authorization") String token, - @Valid @RequestBody UpdateMemberRequestDto request) { + @RequestPart(value = "profileImage", required = false) MultipartFile profileImage, + @RequestPart(value = "userData", required = false) String userDataJson) { token = token.replace("Bearer ", ""); Long memberId = jwtUtil.extractMemberId(token); - UpdateMemberResponseDto updatedMember = memberService.updateMember(memberId, request); - return ResponseEntity.ok(updatedMember); + try { + UpdateMemberRequestDto request = (userDataJson == null || userDataJson.isBlank()) + ? new UpdateMemberRequestDto() + : objectMapper.readValue(userDataJson, UpdateMemberRequestDto.class); + + return memberService.updateMember(memberId, request, profileImage) + .thenApply(ResponseEntity::ok); + } catch (JsonProcessingException e) { + return CompletableFuture.completedFuture(ResponseEntity.badRequest().body(null)); + } } + /** * 회원 탙퇴 */ diff --git a/src/main/java/pawparazzi/back/member/entity/Member.java b/src/main/java/pawparazzi/back/member/entity/Member.java index addcd2c..deea5d8 100644 --- a/src/main/java/pawparazzi/back/member/entity/Member.java +++ b/src/main/java/pawparazzi/back/member/entity/Member.java @@ -36,7 +36,7 @@ public class Member { @Column private String name; - @Column + @Column(name = "profile_image_url", length = 1024) private String profileImageUrl; @OneToMany(mappedBy = "member", cascade = CascadeType.ALL, orphanRemoval = true) diff --git a/src/main/java/pawparazzi/back/member/service/MemberService.java b/src/main/java/pawparazzi/back/member/service/MemberService.java index d791ae4..195ff1f 100644 --- a/src/main/java/pawparazzi/back/member/service/MemberService.java +++ b/src/main/java/pawparazzi/back/member/service/MemberService.java @@ -6,6 +6,9 @@ import org.springframework.security.authentication.BadCredentialsException; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.stereotype.Service; +import org.springframework.web.multipart.MultipartFile; +import pawparazzi.back.S3.S3UploadUtil; +import pawparazzi.back.S3.service.S3AsyncService; import pawparazzi.back.board.entity.Board; import pawparazzi.back.board.repository.BoardMongoRepository; import pawparazzi.back.board.repository.BoardRepository; @@ -19,9 +22,11 @@ import pawparazzi.back.member.repository.MemberRepository; import pawparazzi.back.security.util.JwtUtil; +import java.io.IOException; import java.util.List; import java.util.Optional; import java.util.UUID; +import java.util.concurrent.CompletableFuture; import java.util.stream.Collectors; @@ -34,11 +39,14 @@ public class MemberService { private final JwtUtil jwtUtil; private final BoardRepository boardRepository; private final BoardMongoRepository boardMongoRepository; + private final S3AsyncService s3AsyncService; + private final S3UploadUtil s3UploadUtil; /** * 회원가입 */ - public void registerUser(SignUpRequestDto request) { + @Transactional + public CompletableFuture registerUser(SignUpRequestDto request, MultipartFile profileImage) { if (memberRepository.existsByEmail(request.getEmail())) { throw new IllegalArgumentException("이미 가입된 이메일입니다."); } @@ -47,11 +55,18 @@ public void registerUser(SignUpRequestDto request) { throw new IllegalArgumentException("이미 사용 중인 닉네임입니다."); } - // 비밀번호 암호화 String encodedPassword = passwordEncoder.encode(request.getPassword()); - Member member = new Member(request.getEmail(), encodedPassword, request.getNickName(), request.getProfileImageUrl(), request.getName()); - memberRepository.save(member); + // 프로필 이미지 업로드 (비동기 처리) + String pathPrefix = "profile_images/" + request.getNickName(); + String defaultImageUrl = "https://default-image-url.com/default-profile.png"; + CompletableFuture profileImageUrlFuture = s3UploadUtil.uploadImageAsync(profileImage, pathPrefix, defaultImageUrl); + + // 업로드 완료 후 Member 저장 + return profileImageUrlFuture.thenAccept(profileImageUrl -> { + Member member = new Member(request.getEmail(), encodedPassword, request.getNickName(), profileImageUrl, request.getName()); + memberRepository.save(member); + }); } /** @@ -69,7 +84,6 @@ public String login(LoginRequestDto request) { throw new BadCredentialsException("이메일 또는 비밀번호가 잘못되었습니다."); } - // JWT를 memberId 기반으로 생성 return jwtUtil.generateIdToken(member.getId()); } @@ -84,33 +98,72 @@ public Member findById(Long id) { /** * 회원 정보 수정 */ - @Transactional - public UpdateMemberResponseDto updateMember(Long memberId, UpdateMemberRequestDto request) { + public CompletableFuture updateMember(Long memberId, UpdateMemberRequestDto request, MultipartFile newProfileImage) { Member member = memberRepository.findById(memberId) .orElseThrow(() -> new EntityNotFoundException("회원 정보를 찾을 수 없습니다.")); - if (request.getNickName() != null && !request.getNickName().isBlank()) { + // 닉네임 변경 + if (request.getNickName() != null && !request.getNickName().isBlank() && + !request.getNickName().equals(member.getNickName())) { if (memberRepository.existsByNickName(request.getNickName())) { throw new IllegalArgumentException("이미 사용 중인 닉네임입니다."); } member.setNickName(request.getNickName()); } + // 이름 변경 if (request.getName() != null && !request.getName().isBlank()) { member.setName(request.getName()); } - if (request.getProfileImageUrl() != null && !request.getProfileImageUrl().isBlank()) { - member.setProfileImageUrl(request.getProfileImageUrl()); + String pathPrefix = "profile_images/" + member.getNickName(); + String defaultImageUrl = "https://default-image-url.com/default-profile.png"; + String oldProfileImageUrl = member.getProfileImageUrl(); + + // 새로운 프로필 이미지 업로드 + CompletableFuture profileImageUrlFuture = s3UploadUtil.uploadImageAsync(newProfileImage, pathPrefix, defaultImageUrl); + + return profileImageUrlFuture.thenCompose(newProfileImageUrl -> { + member.setProfileImageUrl(newProfileImageUrl); + memberRepository.save(member); + + // 기존 프로필 이미지 삭제 + if (oldProfileImageUrl != null && !oldProfileImageUrl.equals(defaultImageUrl)) { + String oldFileName = extractFileName(oldProfileImageUrl); + return s3AsyncService.deleteFile("profile_images/" + oldFileName) + .exceptionally(ex -> { + System.err.println("S3 파일 삭제 실패: " + ex.getMessage()); + return null; + }) + .thenApply(ignored -> new UpdateMemberResponseDto( + member.getId(), + member.getEmail(), + member.getNickName(), + member.getName(), + member.getProfileImageUrl() + )); + } + + return CompletableFuture.completedFuture(new UpdateMemberResponseDto( + member.getId(), + member.getEmail(), + member.getNickName(), + member.getName(), + member.getProfileImageUrl() + )); + }); + } + + public String extractFileName(String imageUrl) { + if (imageUrl == null || imageUrl.isBlank()) { + return null; + } + + if (imageUrl.contains("/profile_images/")) { + return imageUrl.substring(imageUrl.lastIndexOf("/profile_images/") + "/profile_images/".length()); } - return new UpdateMemberResponseDto( - member.getId(), - member.getEmail(), - member.getNickName(), - member.getName(), - member.getProfileImageUrl() - ); + return imageUrl.substring(imageUrl.lastIndexOf("/") + 1); } /** diff --git a/src/main/java/pawparazzi/back/pet/controller/PetController.java b/src/main/java/pawparazzi/back/pet/controller/PetController.java index b2ebe4d..8f52f10 100644 --- a/src/main/java/pawparazzi/back/pet/controller/PetController.java +++ b/src/main/java/pawparazzi/back/pet/controller/PetController.java @@ -1,18 +1,21 @@ package pawparazzi.back.pet.controller; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; import lombok.RequiredArgsConstructor; +import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; -import pawparazzi.back.member.entity.Member; -import pawparazzi.back.member.service.MemberService; +import org.springframework.web.multipart.MultipartFile; import pawparazzi.back.pet.dto.PetRegisterRequestDto; import pawparazzi.back.pet.dto.PetResponseDto; import pawparazzi.back.pet.dto.PetUpdateDto; -import pawparazzi.back.pet.entity.Pet; import pawparazzi.back.pet.service.PetService; +import pawparazzi.back.security.util.JwtUtil; import java.util.List; -import java.util.stream.Collectors; +import java.util.Map; +import java.util.concurrent.CompletableFuture; @RestController @RequestMapping("/api/pets") @@ -20,64 +23,86 @@ public class PetController { private final PetService petService; - private final MemberService memberService; + private final JwtUtil jwtUtil; + private final ObjectMapper objectMapper; - //펫 등록 - @PostMapping("/register") - public ResponseEntity registerPet( - @RequestHeader ("Authorization") String token, - @RequestBody PetRegisterRequestDto registerDto){ + /** + * 반려동물 등록 + */ + @PostMapping(value = "/register", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + public CompletableFuture> registerPet( + @RequestHeader("Authorization") String token, + @RequestPart("petData") String petDataJson, + @RequestPart(value = "petImage", required = false) MultipartFile petImage) { - Pet pet = petService.registerPet(registerDto, token); + Long userId = jwtUtil.extractMemberId(token.replace("Bearer ", "")); - if(pet.getMember() != null) { - pet.getMember().getNickName(); - pet.getMember().getEmail(); + PetRegisterRequestDto registerDto; + try { + registerDto = objectMapper.readValue(petDataJson, PetRegisterRequestDto.class); + } catch (JsonProcessingException e) { + return CompletableFuture.completedFuture(ResponseEntity.badRequest().build()); } - return ResponseEntity.ok(new PetResponseDto(pet)); + + return petService.registerPet(userId, registerDto, petImage) + .thenApply(ResponseEntity::ok); } - //회원별 전체 펫 조회 + /** + * 회원별 반려동물 목록 조회 + */ @GetMapping("/all") - public ResponseEntity> petList( - @RequestHeader("Authorization") String token - ){ - List pets = petService.getPetsByMember(token); - List response = pets.stream() - .map(PetResponseDto::new) - .toList(); - - return ResponseEntity.ok(response); + public ResponseEntity> getAllPets(@RequestHeader("Authorization") String token) { + Long userId = jwtUtil.extractMemberId(token.replace("Bearer ", "")); + List pets = petService.getPetsByMember(userId); + return ResponseEntity.ok(pets); } - //펫 상세조회 + /** + * 반려동물 상세 조회 + */ @GetMapping("/{petId}") - public ResponseEntity getPet( - @PathVariable Long petId, - @RequestHeader("Authorization") String token){ - - Pet pet = petService.getPetById(token, petId); - return ResponseEntity.ok(new PetResponseDto(pet)); + public ResponseEntity getPet(@PathVariable Long petId, @RequestHeader("Authorization") String token) { + Long userId = jwtUtil.extractMemberId(token.replace("Bearer ", "")); + return ResponseEntity.ok(petService.getPetById(petId, userId)); } - //반려동물 정보 수정 - @PutMapping("/{petId}") - public ResponseEntity updatePet( + /** + * 반려동물 정보 수정 + */ + @PatchMapping(value = "/{petId}", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + public CompletableFuture> updatePet( @PathVariable Long petId, - @RequestBody PetUpdateDto updateDto, - @RequestHeader("Authorization") String token - ){ - Pet updatedPet = petService.updatePet(petId, updateDto, token); - return ResponseEntity.ok(new PetResponseDto(updatedPet)); + @RequestHeader("Authorization") String token, + @RequestPart(value = "petData", required = false) String petDataJson, + @RequestPart(value = "petImage", required = false) MultipartFile petImage) { + + Long userId = jwtUtil.extractMemberId(token.replace("Bearer ", "")); + + PetUpdateDto updateDto; + try { + updateDto = (petDataJson != null && !petDataJson.isBlank()) + ? objectMapper.readValue(petDataJson, PetUpdateDto.class) + : new PetUpdateDto(); + } catch (JsonProcessingException e) { + return CompletableFuture.completedFuture(ResponseEntity.badRequest().build()); + } + + return petService.updatePet(petId, userId, updateDto, petImage) + .thenApply(ResponseEntity::ok); } - //반려동물 삭제 + /** + * 반려동물 삭제 + */ @DeleteMapping("/{petId}") - public ResponseEntity deletePet( + public CompletableFuture>> deletePet( @PathVariable Long petId, - @RequestHeader("Authorization") String token){ - petService.deletePet(petId, token); - return ResponseEntity.noContent().build(); - } + @RequestHeader("Authorization") String token) { -} + Long userId = jwtUtil.extractMemberId(token.replace("Bearer ", "")); + + return petService.deletePet(petId, userId) + .thenApply(ignored -> ResponseEntity.ok(Map.of("message", "반려동물이 삭제되었습니다."))); + } +} \ No newline at end of file diff --git a/src/main/java/pawparazzi/back/pet/dto/PetRegisterRequestDto.java b/src/main/java/pawparazzi/back/pet/dto/PetRegisterRequestDto.java index 5d0c22a..849b89b 100644 --- a/src/main/java/pawparazzi/back/pet/dto/PetRegisterRequestDto.java +++ b/src/main/java/pawparazzi/back/pet/dto/PetRegisterRequestDto.java @@ -4,7 +4,6 @@ import jakarta.validation.constraints.NotNull; import lombok.Getter; import lombok.Setter; -import pawparazzi.back.pet.entity.Pet; import pawparazzi.back.pet.entity.Type; import java.time.LocalDate; @@ -22,5 +21,4 @@ public class PetRegisterRequestDto { @NotNull private LocalDate birthDate; - private String petImg; } diff --git a/src/main/java/pawparazzi/back/pet/entity/Pet.java b/src/main/java/pawparazzi/back/pet/entity/Pet.java index 57a656e..3e132e1 100644 --- a/src/main/java/pawparazzi/back/pet/entity/Pet.java +++ b/src/main/java/pawparazzi/back/pet/entity/Pet.java @@ -6,7 +6,6 @@ import pawparazzi.back.member.entity.Member; import java.time.LocalDate; -import java.util.Date; @Entity @Getter @@ -16,7 +15,7 @@ public class Pet { @Id - @GeneratedValue + @GeneratedValue(strategy = GenerationType.IDENTITY) private Long petId; @Column(nullable = false) @@ -32,22 +31,22 @@ public class Pet { private String petImg; @JsonIgnore - @ManyToOne(fetch = FetchType.LAZY) + @ManyToOne(fetch = FetchType.EAGER) @JoinColumn(name = "user_id") private Member member; -// public void setMember(Member member) { -// this.member = member; -// if(!member.getPets().contains(this)) { -// member.getPets().add(this); -// } -// } - public void setMember(Member member) { this.member = member; - // 이미 포함되어 있는지 확인 - if(member != null && !member.getPets().contains(this)) { + if (member != null && !member.getPets().contains(this)) { member.getPets().add(this); } } + + public Pet(String name, Type type, LocalDate birthDate, String petImg, Member member) { + this.name = name; + this.type = type; + this.birthDate = birthDate; + this.petImg = petImg; + this.member = member; + } } diff --git a/src/main/java/pawparazzi/back/pet/service/PetService.java b/src/main/java/pawparazzi/back/pet/service/PetService.java index fa904a7..4d558e8 100644 --- a/src/main/java/pawparazzi/back/pet/service/PetService.java +++ b/src/main/java/pawparazzi/back/pet/service/PetService.java @@ -1,20 +1,24 @@ package pawparazzi.back.pet.service; +import jakarta.persistence.EntityNotFoundException; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.multipart.MultipartFile; +import pawparazzi.back.S3.S3UploadUtil; +import pawparazzi.back.S3.service.S3AsyncService; import pawparazzi.back.member.entity.Member; import pawparazzi.back.member.repository.MemberRepository; -import pawparazzi.back.member.service.MemberService; import pawparazzi.back.pet.dto.PetRegisterRequestDto; +import pawparazzi.back.pet.dto.PetResponseDto; import pawparazzi.back.pet.dto.PetUpdateDto; import pawparazzi.back.pet.entity.Pet; import pawparazzi.back.pet.repository.PetRepository; -import pawparazzi.back.security.util.JwtUtil; -import java.util.ArrayList; import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.stream.Collectors; @Slf4j @Service @@ -23,89 +27,137 @@ public class PetService { private final PetRepository petRepository; private final MemberRepository memberRepository; - private final JwtUtil jwtUtil; - - //펫 등록 + private final S3UploadUtil s3UploadUtil; + private final S3AsyncService s3AsyncService; + /** + * 반려동물 등록 + */ @Transactional - public Pet registerPet(PetRegisterRequestDto registerDto, String token) { - Long userId = jwtUtil.extractMemberId(token.replace("Bearer ", "")); - + public CompletableFuture registerPet(Long userId, PetRegisterRequestDto registerDto, MultipartFile petImage) { Member member = memberRepository.findById(userId) .orElseThrow(() -> new IllegalArgumentException("사용자를 찾을 수 없습니다.")); - Pet pet = new Pet(); - pet.setName(registerDto.getName()); - pet.setType(registerDto.getType()); - pet.setBirthDate(registerDto.getBirthDate()); - pet.setPetImg(registerDto.getPetImg()); - pet.setMember(member); + String pathPrefix = "pet_images/" + member.getNickName(); + String defaultImageUrl = "https://default-image-url.com/default-pet.png"; - return petRepository.save(pet); + // S3 이미지 업로드 (비동기 처리) + CompletableFuture petImageUrlFuture = s3UploadUtil.uploadImageAsync(petImage, pathPrefix, defaultImageUrl); + + return petImageUrlFuture.thenApply(petImageUrl -> { + Pet pet = new Pet(registerDto.getName(), registerDto.getType(), registerDto.getBirthDate(), petImageUrl, member); + member.addPet(pet); // Member에 반려동물 추가 + petRepository.save(pet); + return new PetResponseDto(pet); + }); } - //펫 조회 + /** + * 회원별 반려동물 목록 조회 + */ @Transactional(readOnly = true) - public List getPetsByMember(String token) { - Long userId = jwtUtil.extractMemberId(token.replace("Bearer ", "")); - + public List getPetsByMember(Long userId) { memberRepository.findById(userId) - .orElseThrow(() -> new IllegalArgumentException("사용자를 찾을 수 없습니다.")); - System.out.println("userId = " + userId); - - List pets = petRepository.findPetsWithMemberByUserId(userId); - - for(Pet pet : pets) { - System.out.println("pet.getName() = " + pet.getName()); - System.out.println("pet.getMember().getNickName() = " + pet.getMember().getNickName()); - System.out.println("pet.getMember().getNickName() = " + pet.getMember().getEmail()); - } + .orElseThrow(() -> new EntityNotFoundException("사용자를 찾을 수 없습니다.")); - for (Pet pet : pets) { - if (pet.getMember() != null) { - // Member 엔티티 강제 초기화 - pet.getMember().getNickName(); - pet.getMember().getEmail(); - } - } - return pets; + return petRepository.findByMemberId(userId).stream() + .map(PetResponseDto::new) + .collect(Collectors.toList()); } - //펫 상세조회 + /** + * 반려동물 상세 조회 + */ @Transactional(readOnly = true) - public Pet getPetById(String token, Long petId) { - Long userId = jwtUtil.extractMemberId(token.replace("Bearer ", "")); + public PetResponseDto getPetById(Long petId, Long userId) { + Pet pet = petRepository.findById(petId) + .orElseThrow(() -> new EntityNotFoundException("반려동물을 찾을 수 없습니다.")); - memberRepository.findById(userId) - .orElseThrow(() -> new IllegalArgumentException("사용자를 찾을 수 없습니다.")); + if (!pet.getMember().getId().equals(userId)) { + throw new IllegalArgumentException("해당 반려동물을 조회할 권한이 없습니다."); + } - return petRepository.findById(petId) - .orElseThrow(() -> new IllegalArgumentException("Pet not found")); + return new PetResponseDto(pet); } + /** + * 반려동물 정보 수정 + */ @Transactional - public Pet updatePet(Long petId, PetUpdateDto updateDto, String token) { - Long userId = jwtUtil.extractMemberId(token.replace("Bearer ", "")); + public CompletableFuture updatePet(Long petId, Long userId, PetUpdateDto updateDto, MultipartFile petImage) { + Pet pet = petRepository.findById(petId) + .orElseThrow(() -> new EntityNotFoundException("반려동물을 찾을 수 없습니다.")); - memberRepository.findById(userId) - .orElseThrow(() -> new IllegalArgumentException("사용자를 찾을 수 없습니다.")); - Pet pet = getPetById(token, petId); - pet.setName(updateDto.getName()); - pet.setType(updateDto.getType()); - pet.setBirthDate(updateDto.getBirthDate()); - pet.setPetImg(updateDto.getPetImg()); - return petRepository.save(pet); + if (!pet.getMember().getId().equals(userId)) { + throw new IllegalArgumentException("해당 반려동물을 수정할 권한이 없습니다."); + } + + String pathPrefix = "pet_images/" + pet.getMember().getNickName(); + String defaultImageUrl = "https://default-image-url.com/default-pet.png"; + String oldImageUrl = pet.getPetImg(); + + // S3 이미지 업로드 (petImage가 있을 때만 비동기 처리) + CompletableFuture newImageFuture = (petImage != null) + ? s3UploadUtil.uploadImageAsync(petImage, pathPrefix, defaultImageUrl) + : CompletableFuture.completedFuture(oldImageUrl); + + // 부분 업데이트 적용 (null이 아닌 값만 반영) + if (updateDto.getName() != null) pet.setName(updateDto.getName()); + if (updateDto.getType() != null) pet.setType(updateDto.getType()); + if (updateDto.getBirthDate() != null) pet.setBirthDate(updateDto.getBirthDate()); + + return newImageFuture.thenApply(newImageUrl -> { + pet.setPetImg(newImageUrl); + petRepository.save(pet); + return new PetResponseDto(pet); + }).thenCombineAsync( // 기존 S3 이미지 삭제를 비동기 병렬 실행 + (oldImageUrl != null && !oldImageUrl.equals(defaultImageUrl)) + ? s3AsyncService.deleteFile("pet_images/" + extractFileName(oldImageUrl)) + : CompletableFuture.completedFuture(null), + (updatedPet, ignored) -> updatedPet // 이미지 삭제 완료 여부와 상관없이 업데이트된 Pet 반환 + ); } + /** + * 반려동물 삭제 + */ @Transactional - public void deletePet(Long petId, String token) { - Long userId = jwtUtil.extractMemberId(token.replace("Bearer ", "")); + public CompletableFuture deletePet(Long petId, Long userId) { + Pet pet = petRepository.findById(petId) + .orElseThrow(() -> new EntityNotFoundException("반려동물을 찾을 수 없습니다.")); - memberRepository.findById(userId) - .orElseThrow(() -> new IllegalArgumentException("사용자를 찾을 수 없습니다.")); - Pet pet = getPetById(token, petId); - pet.getMember().getPets().remove(pet); + if (!pet.getMember().getId().equals(userId)) { + throw new IllegalArgumentException("해당 반려동물을 삭제할 권한이 없습니다."); + } + + String petImageUrl = pet.getPetImg(); + String defaultImageUrl = "https://default-image-url.com/default-pet.png"; + + // 먼저 DB에서 반려동물 삭제 petRepository.delete(pet); + + // S3에서 기존 반려동물 이미지 삭제 + if (petImageUrl != null && !petImageUrl.equals(defaultImageUrl)) { + String fileName = extractFileName(petImageUrl); + return s3AsyncService.deleteFile("pet_images/" + fileName) + .exceptionally(ex -> { + System.err.println("S3 이미지 삭제 실패: " + ex.getMessage()); + return null; + }); + } + + return CompletableFuture.completedFuture(null); } -} + private String extractFileName(String imageUrl) { + if (imageUrl == null || imageUrl.isBlank()) { + return null; + } + + if (imageUrl.contains("/pet_images/")) { + return imageUrl.substring(imageUrl.lastIndexOf("/pet_images/") + "/pet_images/".length()); + } + + return imageUrl.substring(imageUrl.lastIndexOf("/") + 1); + } +} \ No newline at end of file diff --git a/src/main/java/pawparazzi/back/security/config/SecurityConfig.java b/src/main/java/pawparazzi/back/security/config/SecurityConfig.java index 34c7559..74b9710 100644 --- a/src/main/java/pawparazzi/back/security/config/SecurityConfig.java +++ b/src/main/java/pawparazzi/back/security/config/SecurityConfig.java @@ -35,8 +35,11 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti .csrf(csrf -> csrf.disable()) .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .authorizeHttpRequests(auth -> auth + // 인증 없이 접근 가능한 API .requestMatchers("/api/auth/**").permitAll() + .requestMatchers("/api/async/images/**").permitAll() + .requestMatchers("/api/pets/**").permitAll() .requestMatchers(HttpMethod.GET, "/api/boards/**").permitAll() .requestMatchers(HttpMethod.GET, "/api/comments/**").permitAll() .requestMatchers(HttpMethod.GET, "/api/replies/**").permitAll() diff --git a/src/main/java/pawparazzi/back/walk/controller/WalkController.java b/src/main/java/pawparazzi/back/walk/controller/WalkController.java new file mode 100644 index 0000000..4be6668 --- /dev/null +++ b/src/main/java/pawparazzi/back/walk/controller/WalkController.java @@ -0,0 +1,109 @@ +package pawparazzi.back.walk.controller; + +import lombok.RequiredArgsConstructor; +import org.springframework.format.annotation.DateTimeFormat; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; +import pawparazzi.back.security.util.JwtUtil; +import pawparazzi.back.walk.dto.WalkRequestDto; +import pawparazzi.back.walk.dto.WalkResponseDto; +import pawparazzi.back.walk.entity.Walk; +import pawparazzi.back.walk.repository.WalkRepository; +import pawparazzi.back.walk.service.WalkService; + +import java.time.LocalDateTime; +import java.time.ZonedDateTime; +import java.util.List; +import java.util.NoSuchElementException; + +@RestController +@RequestMapping("/api/walk") +@RequiredArgsConstructor +public class WalkController { + + private final JwtUtil jwtUtil; + private final WalkService walkService; + + //산책 기록 생성 + @PostMapping + public ResponseEntity createWalk( + @RequestBody WalkRequestDto requestDto, + @RequestHeader("Authorization") String token + ) { + Long userId = jwtUtil.extractMemberId(token.replace("Bearer ", "")); + WalkResponseDto responseDto = walkService.createWalk(requestDto, userId); + return ResponseEntity.status(HttpStatus.CREATED).body(responseDto); + } + + //산책 기록 조회 (산책 기록 아이디로) + @GetMapping("/{walkId}") + public ResponseEntity getWalk( + @PathVariable Long walkId, + @RequestHeader("Authorization") String token) { + try{ + Long userId = jwtUtil.extractMemberId(token.replace("Bearer ", "")); + WalkResponseDto responseDto = walkService.getWalkById(walkId, userId); + return ResponseEntity.ok(responseDto); + } catch (NoSuchElementException e) { + return ResponseEntity.notFound().build(); + } + } + + //산책 기록 삭제 (산책 기록 아이디로) + @DeleteMapping("/{walkId}") + public ResponseEntity deleteWalk( + @PathVariable Long walkId, + @RequestHeader("Authorization") String token) { + try{ + Long userId = jwtUtil.extractMemberId(token.replace("Bearer ", "")); + walkService.deleteWalk(walkId, userId); + return ResponseEntity.noContent().build(); + } catch (NoSuchElementException e) { + return ResponseEntity.notFound().build(); + } + } + + //펫별 산책 기록 조회 + @GetMapping("/pet/{petId}") + public ResponseEntity> getWalkByPet( + @PathVariable Long petId, + @RequestHeader("Authorization") String token) { + try { + Long userId = jwtUtil.extractMemberId(token.replace("Bearer ", "")); + List walks = walkService.getWalksByPetId(petId, userId); + return ResponseEntity.ok(walks); + } catch (NoSuchElementException e) { + return ResponseEntity.notFound().build(); + } + } + + //날짜별 산책 기록 조회 + @GetMapping("/date") + public ResponseEntity> getWalkByPetDate( + @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime date, + @RequestHeader("Authorization") String token){ + try{ + Long userId = jwtUtil.extractMemberId(token.replace("Bearer ", "")); + List walks = walkService.getWalksByDate(date, userId); + return ResponseEntity.ok(walks); + } catch (NoSuchElementException e) { + return ResponseEntity.notFound().build(); + } + } + + //날짜와 펫별 산책 기록 조회 + @GetMapping("/pet/{petId}/date") + public ResponseEntity> getWalkByPetAndDate( + @PathVariable Long petId, + @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime date, + @RequestHeader("Authorization") String token) { + try { + Long userId = jwtUtil.extractMemberId(token.replace("Bearer ", "")); + List walks = walkService.getWalksByPetIdAndDate(petId, date, userId); + return ResponseEntity.ok(walks); + } catch (NoSuchElementException e) { + return ResponseEntity.notFound().build(); + } + } +} \ No newline at end of file diff --git a/src/main/java/pawparazzi/back/walk/dto/LocationPointDto.java b/src/main/java/pawparazzi/back/walk/dto/LocationPointDto.java new file mode 100644 index 0000000..f8c68bd --- /dev/null +++ b/src/main/java/pawparazzi/back/walk/dto/LocationPointDto.java @@ -0,0 +1,21 @@ +package pawparazzi.back.walk.dto; + +import com.fasterxml.jackson.annotation.JsonFormat; +import lombok.*; + +import java.time.LocalDateTime; +import java.time.ZonedDateTime; + +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +public class LocationPointDto { + private Double latitude; + + private Double longitude; + + @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd'T'HH:mm:ss", timezone = "UTC") + private LocalDateTime timestamp; + +} \ No newline at end of file diff --git a/src/main/java/pawparazzi/back/walk/dto/WalkRequestDto.java b/src/main/java/pawparazzi/back/walk/dto/WalkRequestDto.java new file mode 100644 index 0000000..f6804ab --- /dev/null +++ b/src/main/java/pawparazzi/back/walk/dto/WalkRequestDto.java @@ -0,0 +1,26 @@ +package pawparazzi.back.walk.dto; + +import com.fasterxml.jackson.annotation.JsonFormat; +import lombok.Data; +import lombok.Getter; + +import java.time.LocalDateTime; +import java.time.ZonedDateTime; +import java.util.List; + +@Data +public class WalkRequestDto { + private Long petId; + + @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd'T'HH:mm:ss") + private LocalDateTime startTime; + + @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd'T'HH:mm:ss") + private LocalDateTime endTime; + + private List route; + + private Double distance; + + private Double averageSpeed; +} \ No newline at end of file diff --git a/src/main/java/pawparazzi/back/walk/dto/WalkResponseDto.java b/src/main/java/pawparazzi/back/walk/dto/WalkResponseDto.java new file mode 100644 index 0000000..8d733cc --- /dev/null +++ b/src/main/java/pawparazzi/back/walk/dto/WalkResponseDto.java @@ -0,0 +1,51 @@ +package pawparazzi.back.walk.dto; + +import com.fasterxml.jackson.annotation.JsonFormat; +import lombok.Data; +import pawparazzi.back.pet.entity.Pet; +import pawparazzi.back.pet.entity.Type; + +import java.time.LocalDateTime; +import java.time.ZonedDateTime; +import java.util.List; + +@Data +public class WalkResponseDto { + private Long id; + private PetDto pet; + + @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd'T'HH:mm:ss") + private LocalDateTime startTime; + + @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd'T'HH:mm:ss") + private LocalDateTime endTime; + + private List route; + private Double distance; + private Double averageSpeed; + + @Data + public static class PetDto { + private Long petId; + private String name; + private Type type; + private String petImg; + + public PetDto(Pet pet) { + this.petId = pet.getPetId(); + this.name = pet.getName(); + this.type = pet.getType(); + this.petImg = pet.getPetImg(); + } + } + + public WalkResponseDto(Long id, Pet pet, LocalDateTime startTime, LocalDateTime endTime, List route, Double distance, Double averageSpeed) { + this.id = id; + this.pet = new PetDto(pet); + this.startTime = startTime; + this.endTime = endTime; + this.route = route; + this.distance = distance; + this.averageSpeed = averageSpeed; + } +} diff --git a/src/main/java/pawparazzi/back/walk/entity/LocationPoint.java b/src/main/java/pawparazzi/back/walk/entity/LocationPoint.java new file mode 100644 index 0000000..68f2bc8 --- /dev/null +++ b/src/main/java/pawparazzi/back/walk/entity/LocationPoint.java @@ -0,0 +1,36 @@ +package pawparazzi.back.walk.entity; + +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import pawparazzi.back.pet.entity.Pet; + +import java.time.LocalDateTime; +import java.time.ZonedDateTime; + +@Entity +@Table(name = "location_points") +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +public class LocationPoint { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false) + private Double latitude; + + @Column(nullable = false) + private Double longitude; + + @Column(nullable = false) + private LocalDateTime timestamp; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "walk_id") + private Walk walk; +} \ No newline at end of file diff --git a/src/main/java/pawparazzi/back/walk/entity/Walk.java b/src/main/java/pawparazzi/back/walk/entity/Walk.java new file mode 100644 index 0000000..9df86d4 --- /dev/null +++ b/src/main/java/pawparazzi/back/walk/entity/Walk.java @@ -0,0 +1,46 @@ +package pawparazzi.back.walk.entity; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import pawparazzi.back.member.entity.Member; +import pawparazzi.back.pet.entity.Pet; + +import java.time.LocalDateTime; +import java.time.ZonedDateTime; +import java.util.ArrayList; +import java.util.List; + +@Entity +@NoArgsConstructor +@AllArgsConstructor +@Getter +@Setter +public class Walk { + @Id + @GeneratedValue + private Long id; + + @JsonIgnore + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "pet_id") + private Pet pet; + + @Column(name = "start_time", nullable = false) + private LocalDateTime startTime; + + @Column(name = "end_time", nullable = false) + private LocalDateTime endTime; + + @Column(nullable = false) + private Double distance; + + @Column(name = "average_speed", nullable = false) + private Double averageSpeed; + + @OneToMany(mappedBy = "walk", cascade = CascadeType.ALL, orphanRemoval = true) + private List route = new ArrayList<>(); +} \ No newline at end of file diff --git a/src/main/java/pawparazzi/back/walk/entity/WalkMapper.java b/src/main/java/pawparazzi/back/walk/entity/WalkMapper.java new file mode 100644 index 0000000..4ad7776 --- /dev/null +++ b/src/main/java/pawparazzi/back/walk/entity/WalkMapper.java @@ -0,0 +1,63 @@ +package pawparazzi.back.walk.entity; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import pawparazzi.back.pet.entity.Pet; +import pawparazzi.back.pet.repository.PetRepository; +import pawparazzi.back.walk.dto.LocationPointDto; +import pawparazzi.back.walk.dto.WalkRequestDto; +import pawparazzi.back.walk.dto.WalkResponseDto; + +import java.util.List; +import java.util.stream.Collectors; + +@Component +@RequiredArgsConstructor +public class WalkMapper { + + private final PetRepository petRepository; + + public Walk toEntity(WalkRequestDto dto) { + Pet pet = petRepository.findById(dto.getPetId()) + .orElseThrow(() -> new IllegalArgumentException("Pet not found with id: " + dto.getPetId())); + + Walk walk = new Walk(); + walk.setPet(pet); + walk.setStartTime(dto.getStartTime()); + walk.setEndTime(dto.getEndTime()); + walk.setDistance(dto.getDistance()); + walk.setAverageSpeed(dto.getAverageSpeed()); + + List locationPoints = dto.getRoute().stream().map(pointDto -> { + LocationPoint point = new LocationPoint(); + point.setLatitude(pointDto.getLatitude()); + point.setLongitude(pointDto.getLongitude()); + point.setTimestamp(pointDto.getTimestamp()); + point.setWalk(walk); + return point; + }).collect(Collectors.toList()); + + walk.setRoute(locationPoints); + return walk; + } + + public WalkResponseDto toDto(Walk walk) { + return new WalkResponseDto( + walk.getId(), + walk.getPet(), + walk.getStartTime(), + walk.getEndTime(), + walk.getRoute().stream().map(this::toLocationPointDto).collect(Collectors.toList()), + walk.getDistance(), + walk.getAverageSpeed() + ); + } + + private LocationPointDto toLocationPointDto(LocationPoint point) { + return new LocationPointDto( + point.getLatitude(), + point.getLongitude(), + point.getTimestamp() + ); + } +} diff --git a/src/main/java/pawparazzi/back/walk/repository/WalkRepository.java b/src/main/java/pawparazzi/back/walk/repository/WalkRepository.java new file mode 100644 index 0000000..2320bfd --- /dev/null +++ b/src/main/java/pawparazzi/back/walk/repository/WalkRepository.java @@ -0,0 +1,26 @@ +package pawparazzi.back.walk.repository; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; +import pawparazzi.back.walk.entity.Walk; + +import java.time.LocalDateTime; +import java.time.ZonedDateTime; +import java.util.List; + +@Repository +public interface WalkRepository extends JpaRepository { + // 반려동물 ID로 산책 목록 조회 + @Query("SELECT w FROM Walk w WHERE w.pet.petId = :petId ORDER BY w.startTime DESC") + List findByPetIdOrderByStartTimeDesc(@Param("petId") Long petId); + + // 특정 날짜의 산책 조회 (날짜만 비교) + @Query("SELECT w FROM Walk w WHERE FUNCTION('DATE', w.startTime) = FUNCTION('DATE', :date) ORDER BY w.startTime DESC") + List findByDate(@Param("date") LocalDateTime date); + + // Add this method to WalkRepository interface + @Query("SELECT w FROM Walk w WHERE w.pet.petId = :petId AND FUNCTION('DATE', w.startTime) = FUNCTION('DATE', :date) ORDER BY w.startTime DESC") + List findByPetIdAndDate(@Param("petId") Long petId, @Param("date") LocalDateTime date); +} diff --git a/src/main/java/pawparazzi/back/walk/service/WalkService.java b/src/main/java/pawparazzi/back/walk/service/WalkService.java new file mode 100644 index 0000000..7a0ef21 --- /dev/null +++ b/src/main/java/pawparazzi/back/walk/service/WalkService.java @@ -0,0 +1,113 @@ +package pawparazzi.back.walk.service; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import pawparazzi.back.pet.entity.Pet; +import pawparazzi.back.pet.repository.PetRepository; +import pawparazzi.back.walk.dto.WalkRequestDto; +import pawparazzi.back.walk.dto.WalkResponseDto; +import pawparazzi.back.walk.entity.Walk; +import pawparazzi.back.walk.entity.WalkMapper; +import pawparazzi.back.walk.repository.WalkRepository; + +import java.time.LocalDateTime; +import java.time.ZonedDateTime; +import java.util.List; +import java.util.NoSuchElementException; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +public class WalkService { + + private final WalkRepository walkRepository; + private final PetRepository petRepository; + private final WalkMapper walkMapper; + + @Transactional + public WalkResponseDto createWalk(WalkRequestDto requestDto, Long userId) { + Pet pet = petRepository.findById(requestDto.getPetId()) + .orElseThrow(() -> new NoSuchElementException("Pet not found with Id: " + requestDto.getPetId())); + + if (!pet.getMember().getId().equals(userId)) { + throw new NoSuchElementException("Pet does not belong to user"); + } + + Walk walk = walkMapper.toEntity(requestDto); + walk.setPet(pet); + + Walk savedWalk = walkRepository.save(walk); + return walkMapper.toDto(savedWalk); + } + + @Transactional(readOnly = true) + public WalkResponseDto getWalkById(Long walkId, Long userId){ + Walk walk = walkRepository.findById(walkId) + .orElseThrow(() -> new NoSuchElementException("Walk not found with Id: " + walkId)); + + if (!walk.getPet().getMember().getId().equals(userId)) { + throw new NoSuchElementException("Walk does not belong to pet of user"); + } + return walkMapper.toDto(walk); + } + + @Transactional + public void deleteWalk(Long walkId, Long userId){ + Walk walk = walkRepository.findById(walkId) + .orElseThrow(() -> new NoSuchElementException("Walk not found with Id: " + walkId)); + + if (!walk.getPet().getMember().getId().equals(userId)) { + throw new NoSuchElementException("You don't have permission to delete this walk"); + } + + walkRepository.deleteById(walkId); + } + + @Transactional(readOnly = true) + public List getWalksByDate(LocalDateTime date, Long userId) { + List walks = walkRepository.findByDate(date); + + // userId로 필터링: 해당 사용자의 반려동물 산책 기록만 반환 + return walks.stream() + .filter(walk -> walk.getPet().getMember().getId().equals(userId)) + .map(walkMapper::toDto) + .collect(Collectors.toList()); + } + + @Transactional(readOnly = true) + public List getWalksByPetId(Long petId, Long userId){ + Pet pet = petRepository.findById(petId) + .orElseThrow(() -> new NoSuchElementException("Pet not found with Id: " + petId)); + + // 펫 소유자 검증 + if (!pet.getMember().getId().equals(userId)) { + throw new NoSuchElementException("You don't have permission to access this pet's walks"); + } + + // 리포지토리 메서드를 사용하여 산책 목록 조회 + List walks = walkRepository.findByPetIdOrderByStartTimeDesc(petId); + + return walks.stream() + .map(walkMapper::toDto) + .collect(Collectors.toList()); + } + + @Transactional(readOnly = true) + public List getWalksByPetIdAndDate(Long petId, LocalDateTime date, Long userId) { + Pet pet = petRepository.findById(petId) + .orElseThrow(() -> new NoSuchElementException("Pet not found with Id: " + petId)); + + // Verify pet ownership + if (!pet.getMember().getId().equals(userId)) { + throw new NoSuchElementException("You don't have permission to access this pet's walks"); + } + + // Query walks by both petId and date + List walks = walkRepository.findByPetIdAndDate(petId, date); + + return walks.stream() + .map(walkMapper::toDto) + .collect(Collectors.toList()); + } +} \ No newline at end of file diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 710def0..96cf718 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -2,6 +2,14 @@ server: port: 8080 spring: + + servlet: + multipart: + enabled: true + max-file-size: 10MB + max-request-size: 20MB + + datasource: url: ${PAWPARAZZI_DB_URL} username: ${PAWPARAZZI_DB_USERNAME} @@ -49,4 +57,10 @@ jwt: secret: ${JWT_SECRET} expiration: ${JWT_EXPIRATION} +aws: + access-key: ${AWS_ACCESS_KEY} + secret-key: ${AWS_SECRET_KEY} + region: ${AWS_REGION} + s3-bucket: ${AWS_S3_BUCKET} + spring.config.import: optional:.env \ No newline at end of file