From 5fae90b58cc5c9e34c47c7fb9619a2bb428891ae Mon Sep 17 00:00:00 2001 From: hanjunLee00 Date: Wed, 12 Mar 2025 17:57:39 +0900 Subject: [PATCH 01/15] =?UTF-8?q?refactor:=20followerCount,=20followingCou?= =?UTF-8?q?nt=20=EC=97=AD=ED=95=A0=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A->B 팔로우 관계에서 - follwerCount는 B의 팔로워수, followingCount는 A의 팔로잉 수 - 언팔로우시에도 UnfollowResponseDto로 응답 - FollowRequestDto 삭제 --- .../follow/controller/FollowController.java | 8 ++-- .../back/follow/dto/FollowRequestDto.java | 12 ------ .../back/follow/dto/UnfollowResponseDto.java | 22 +++++++++++ .../back/follow/service/FollowService.java | 38 +++++++++++++++++-- 4 files changed, 60 insertions(+), 20 deletions(-) delete mode 100644 src/main/java/pawparazzi/back/follow/dto/FollowRequestDto.java create mode 100644 src/main/java/pawparazzi/back/follow/dto/UnfollowResponseDto.java 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; + } + } From 20ba52fc57f0d1f0df4aa9fd6342bf142a44420b Mon Sep 17 00:00:00 2001 From: geg222 <147246023+geg222@users.noreply.github.com> Date: Wed, 12 Mar 2025 20:35:38 +0900 Subject: [PATCH 02/15] =?UTF-8?q?[FEAT]=20S3=20member=20=EC=9D=B4=EB=AF=B8?= =?UTF-8?q?=EC=A7=80=20=EC=B2=98=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit [FEAT] S3 member 이미지 처리 --- build.gradle | 3 + .../java/pawparazzi/back/BackApplication.java | 17 +--- .../pawparazzi/back/S3/S3AsyncConfig.java | 38 +++++++++ .../S3/controller/ImageAsyncController.java | 47 +++++++++++ .../back/S3/service/S3AsyncService.java | 81 +++++++++++++++++++ .../pawparazzi/back/config/JacksonConfig.java | 14 ++++ .../member/controller/MemberController.java | 48 +++++++++-- .../pawparazzi/back/member/entity/Member.java | 2 +- .../back/member/service/MemberService.java | 64 +++++++++++++-- .../back/security/config/SecurityConfig.java | 1 + src/main/resources/application.yml | 14 ++++ 11 files changed, 299 insertions(+), 30 deletions(-) create mode 100644 src/main/java/pawparazzi/back/S3/S3AsyncConfig.java create mode 100644 src/main/java/pawparazzi/back/S3/controller/ImageAsyncController.java create mode 100644 src/main/java/pawparazzi/back/S3/service/S3AsyncService.java create mode 100644 src/main/java/pawparazzi/back/config/JacksonConfig.java diff --git a/build.gradle b/build.gradle index 10e378f..1327d20 100644 --- a/build.gradle +++ b/build.gradle @@ -33,6 +33,9 @@ dependencies { implementation 'com.fasterxml.jackson.core:jackson-databind' implementation 'org.springframework.boot:spring-boot-starter-oauth2-client' + 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' implementation 'io.jsonwebtoken:jjwt-jackson:0.11.5' diff --git a/src/main/java/pawparazzi/back/BackApplication.java b/src/main/java/pawparazzi/back/BackApplication.java index 7c2942b..fc6428d 100644 --- a/src/main/java/pawparazzi/back/BackApplication.java +++ b/src/main/java/pawparazzi/back/BackApplication.java @@ -1,24 +1,13 @@ package pawparazzi.back; -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")); - 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/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..f213742 --- /dev/null +++ b/src/main/java/pawparazzi/back/S3/service/S3AsyncService.java @@ -0,0 +1,81 @@ +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.PutObjectRequest; +import software.amazon.awssdk.services.s3.model.DeleteObjectRequest; + +import java.nio.ByteBuffer; +import java.util.concurrent.CompletableFuture; + +@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에서 비동기적으로 파일 삭제 + */ + @Async + public CompletableFuture deleteFile(String fileName) { + DeleteObjectRequest deleteObjectRequest = DeleteObjectRequest.builder() + .bucket(bucketName) + .key(fileName) + .build(); + + return s3AsyncClient.deleteObject(deleteObjectRequest) + .thenAccept(response -> { + if (!response.sdkHttpResponse().isSuccessful()) { + throw new RuntimeException("S3 삭제 실패: " + response.sdkHttpResponse().statusCode()); + } + }); + } + + /** + * 기존 프로필 이미지 삭제 후 새 이미지 업로드 + */ + 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); + } +} \ 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..2853807 --- /dev/null +++ b/src/main/java/pawparazzi/back/config/JacksonConfig.java @@ -0,0 +1,14 @@ +package pawparazzi.back.config; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class JacksonConfig { + + @Bean + public ObjectMapper objectMapper() { + return new ObjectMapper(); + } +} \ No newline at end of file diff --git a/src/main/java/pawparazzi/back/member/controller/MemberController.java b/src/main/java/pawparazzi/back/member/controller/MemberController.java index ad8086b..2b6f5b2 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,13 +31,29 @@ public class MemberController { private final JwtUtil jwtUtil; private final MemberService memberService; + private final S3AsyncService s3AsyncService; + private final ObjectMapper objectMapper; + /** * 회원 가입 */ - @PostMapping("/signup") - public ResponseEntity registerUser(@Valid @RequestBody SignUpRequestDto request) { - memberService.registerUser(request); + @PostMapping(value = "/signup", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + public ResponseEntity registerUser( + @RequestPart(value = "profileImage", required = false) MultipartFile profileImage, + @RequestPart("userData") String userDataJson) { + + // JSON 데이터를 DTO로 변환 + ObjectMapper objectMapper = new ObjectMapper(); + SignUpRequestDto request; + try { + request = objectMapper.readValue(userDataJson, SignUpRequestDto.class); + } catch (JsonProcessingException e) { + return ResponseEntity.badRequest().body("Invalid JSON format"); + } + + // 회원가입 처리 + memberService.registerUser(request, profileImage); return ResponseEntity.ok("회원가입 성공"); } @@ -56,17 +80,27 @@ public ResponseEntity getCurrentUser(@RequestHeader("Authorization") Str /** * 사용자 정보 수정 */ - @PatchMapping("/me") + @PatchMapping(value = "/me", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) public ResponseEntity 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); + + UpdateMemberResponseDto updatedMember = memberService.updateMember(memberId, request, profileImage); + return ResponseEntity.ok(updatedMember); + } catch (JsonProcessingException e) { + return 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..3aa3841 100644 --- a/src/main/java/pawparazzi/back/member/service/MemberService.java +++ b/src/main/java/pawparazzi/back/member/service/MemberService.java @@ -6,6 +6,8 @@ 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.service.S3AsyncService; import pawparazzi.back.board.entity.Board; import pawparazzi.back.board.repository.BoardMongoRepository; import pawparazzi.back.board.repository.BoardRepository; @@ -19,6 +21,7 @@ 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; @@ -34,11 +37,13 @@ public class MemberService { private final JwtUtil jwtUtil; private final BoardRepository boardRepository; private final BoardMongoRepository boardMongoRepository; + private final S3AsyncService s3AsyncService; /** * 회원가입 */ - public void registerUser(SignUpRequestDto request) { + @Transactional + public void registerUser(SignUpRequestDto request, MultipartFile profileImage) { if (memberRepository.existsByEmail(request.getEmail())) { throw new IllegalArgumentException("이미 가입된 이메일입니다."); } @@ -47,10 +52,20 @@ 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()); + // 프로필 이미지 업로드 (비동기) + String profileImageUrl = "https://default-image-url.com/default-profile.png"; + if (profileImage != null && !profileImage.isEmpty()) { + try { + String fileName = "profile_images/" + request.getNickName() + "_" + System.currentTimeMillis(); + profileImageUrl = s3AsyncService.uploadFile(fileName, profileImage.getBytes(), profileImage.getContentType()).join(); + } catch (IOException e) { + throw new RuntimeException("파일 업로드 실패: " + e.getMessage()); + } + } + + 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()); } @@ -85,23 +99,45 @@ public Member findById(Long id) { * 회원 정보 수정 */ @Transactional - public UpdateMemberResponseDto updateMember(Long memberId, UpdateMemberRequestDto request) { + public UpdateMemberResponseDto 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()); + // 프로필 이미지 변경 + if (newProfileImage != null && !newProfileImage.isEmpty()) { + try { + // 기존 프로필 이미지 삭제 + if (member.getProfileImageUrl() != null) { + String oldFileName = extractFileName(member.getProfileImageUrl()); + s3AsyncService.deleteFile("profile_images/" + oldFileName); + } + + String newFileName = "profile_images/" + member.getNickName() + "_" + System.currentTimeMillis(); + String contentType = newProfileImage.getContentType(); + byte[] fileData = newProfileImage.getBytes(); + + String newProfileImageUrl = s3AsyncService + .uploadFile(newFileName, fileData, contentType) + .join(); + + member.setProfileImageUrl(newProfileImageUrl); + } catch (IOException e) { + throw new RuntimeException("프로필 이미지 변경 실패: " + e.getMessage()); + } } return new UpdateMemberResponseDto( @@ -113,6 +149,18 @@ public UpdateMemberResponseDto updateMember(Long memberId, UpdateMemberRequestDt ); } + 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 imageUrl.substring(imageUrl.lastIndexOf("/") + 1); + } + /** * 회원 탈퇴 */ diff --git a/src/main/java/pawparazzi/back/security/config/SecurityConfig.java b/src/main/java/pawparazzi/back/security/config/SecurityConfig.java index 34c7559..aaa0245 100644 --- a/src/main/java/pawparazzi/back/security/config/SecurityConfig.java +++ b/src/main/java/pawparazzi/back/security/config/SecurityConfig.java @@ -37,6 +37,7 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti .authorizeHttpRequests(auth -> auth // 인증 없이 접근 가능한 API .requestMatchers("/api/auth/**").permitAll() + .requestMatchers("/api/async/images/**").permitAll() .requestMatchers(HttpMethod.GET, "/api/boards/**").permitAll() .requestMatchers(HttpMethod.GET, "/api/comments/**").permitAll() .requestMatchers(HttpMethod.GET, "/api/replies/**").permitAll() 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 From 5dcf191bf6b25fd89ed55fee43c1474c33808c7a Mon Sep 17 00:00:00 2001 From: geg222 <147246023+geg222@users.noreply.github.com> Date: Wed, 12 Mar 2025 21:54:02 +0900 Subject: [PATCH 03/15] =?UTF-8?q?[FIX]=20backApplication=20.env=20?= =?UTF-8?q?=EB=B6=88=EB=9F=AC=EC=98=A4=EA=B8=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit [FIX] backApplication .env 불러오기 --- src/main/java/pawparazzi/back/BackApplication.java | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/main/java/pawparazzi/back/BackApplication.java b/src/main/java/pawparazzi/back/BackApplication.java index fc6428d..785540f 100644 --- a/src/main/java/pawparazzi/back/BackApplication.java +++ b/src/main/java/pawparazzi/back/BackApplication.java @@ -1,5 +1,6 @@ package pawparazzi.back; +import io.github.cdimascio.dotenv.Dotenv; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.scheduling.annotation.EnableAsync; @@ -8,6 +9,14 @@ @EnableAsync public class BackApplication { public static void main(String[] args) { + 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 From 4b9d8d125a9db9acca66d336ec1f8602c69a8452 Mon Sep 17 00:00:00 2001 From: geg222 <147246023+geg222@users.noreply.github.com> Date: Thu, 13 Mar 2025 15:21:46 +0900 Subject: [PATCH 04/15] =?UTF-8?q?[FIX]=20=EC=97=90=EB=9F=AC=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit [FIX] 에러 수정 --- build.gradle | 2 ++ src/main/java/pawparazzi/back/config/JacksonConfig.java | 6 +++++- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/build.gradle b/build.gradle index 1327d20..c923e3f 100644 --- a/build.gradle +++ b/build.gradle @@ -32,6 +32,8 @@ 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' diff --git a/src/main/java/pawparazzi/back/config/JacksonConfig.java b/src/main/java/pawparazzi/back/config/JacksonConfig.java index 2853807..916fb49 100644 --- a/src/main/java/pawparazzi/back/config/JacksonConfig.java +++ b/src/main/java/pawparazzi/back/config/JacksonConfig.java @@ -1,6 +1,8 @@ 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; @@ -9,6 +11,8 @@ public class JacksonConfig { @Bean public ObjectMapper objectMapper() { - return new ObjectMapper(); + return new ObjectMapper() + .registerModule(new JavaTimeModule()) + .disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS); } } \ No newline at end of file From 66f51cc0af153ebf17a7b8d847ec7cbf50c789b8 Mon Sep 17 00:00:00 2001 From: geg222 <147246023+geg222@users.noreply.github.com> Date: Fri, 14 Mar 2025 13:42:32 +0900 Subject: [PATCH 05/15] =?UTF-8?q?[FEAT]=20Pet=20=EB=93=B1=EB=A1=9D=20S3=20?= =?UTF-8?q?=EC=97=B0=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit [FEAT] Pet 등록 S3 연동 --- .../java/pawparazzi/back/S3/S3UploadUtil.java | 33 +++++ .../member/controller/MemberController.java | 20 ++- .../back/member/service/MemberService.java | 91 ++++++------- .../back/pet/controller/PetController.java | 102 ++++++++------- .../back/pet/dto/PetRegisterRequestDto.java | 2 - .../java/pawparazzi/back/pet/entity/Pet.java | 21 ++- .../back/pet/service/PetService.java | 121 +++++++++--------- .../back/security/config/SecurityConfig.java | 2 + 8 files changed, 219 insertions(+), 173 deletions(-) create mode 100644 src/main/java/pawparazzi/back/S3/S3UploadUtil.java 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..74703ce --- /dev/null +++ b/src/main/java/pawparazzi/back/S3/S3UploadUtil.java @@ -0,0 +1,33 @@ +package pawparazzi.back.S3; + +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; + } + + 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/member/controller/MemberController.java b/src/main/java/pawparazzi/back/member/controller/MemberController.java index 2b6f5b2..c338e1a 100644 --- a/src/main/java/pawparazzi/back/member/controller/MemberController.java +++ b/src/main/java/pawparazzi/back/member/controller/MemberController.java @@ -31,7 +31,6 @@ public class MemberController { private final JwtUtil jwtUtil; private final MemberService memberService; - private final S3AsyncService s3AsyncService; private final ObjectMapper objectMapper; @@ -39,22 +38,21 @@ public class MemberController { * 회원 가입 */ @PostMapping(value = "/signup", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) - public ResponseEntity registerUser( + public CompletableFuture> registerUser( @RequestPart(value = "profileImage", required = false) MultipartFile profileImage, @RequestPart("userData") String userDataJson) { // JSON 데이터를 DTO로 변환 - ObjectMapper objectMapper = new ObjectMapper(); SignUpRequestDto request; try { request = objectMapper.readValue(userDataJson, SignUpRequestDto.class); } catch (JsonProcessingException e) { - return ResponseEntity.badRequest().body("Invalid JSON format"); + return CompletableFuture.completedFuture(ResponseEntity.badRequest().body("Invalid JSON format")); } - // 회원가입 처리 - memberService.registerUser(request, profileImage); - return ResponseEntity.ok("회원가입 성공"); + // 비동기 회원가입 처리 후 응답 반환 + return memberService.registerUser(request, profileImage) + .thenApply(unused -> ResponseEntity.ok("회원가입 성공")); } /** @@ -81,7 +79,7 @@ public ResponseEntity getCurrentUser(@RequestHeader("Authorization") Str * 사용자 정보 수정 */ @PatchMapping(value = "/me", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) - public ResponseEntity updateMember( + public CompletableFuture> updateMember( @RequestHeader("Authorization") String token, @RequestPart(value = "profileImage", required = false) MultipartFile profileImage, @RequestPart(value = "userData", required = false) String userDataJson) { @@ -94,10 +92,10 @@ public ResponseEntity updateMember( ? new UpdateMemberRequestDto() : objectMapper.readValue(userDataJson, UpdateMemberRequestDto.class); - UpdateMemberResponseDto updatedMember = memberService.updateMember(memberId, request, profileImage); - return ResponseEntity.ok(updatedMember); + return memberService.updateMember(memberId, request, profileImage) + .thenApply(ResponseEntity::ok); } catch (JsonProcessingException e) { - return ResponseEntity.badRequest().body(null); + return CompletableFuture.completedFuture(ResponseEntity.badRequest().body(null)); } } diff --git a/src/main/java/pawparazzi/back/member/service/MemberService.java b/src/main/java/pawparazzi/back/member/service/MemberService.java index 3aa3841..195ff1f 100644 --- a/src/main/java/pawparazzi/back/member/service/MemberService.java +++ b/src/main/java/pawparazzi/back/member/service/MemberService.java @@ -7,6 +7,7 @@ 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; @@ -25,6 +26,7 @@ import java.util.List; import java.util.Optional; import java.util.UUID; +import java.util.concurrent.CompletableFuture; import java.util.stream.Collectors; @@ -38,12 +40,13 @@ public class MemberService { private final BoardRepository boardRepository; private final BoardMongoRepository boardMongoRepository; private final S3AsyncService s3AsyncService; + private final S3UploadUtil s3UploadUtil; /** * 회원가입 */ @Transactional - public void registerUser(SignUpRequestDto request, MultipartFile profileImage) { + public CompletableFuture registerUser(SignUpRequestDto request, MultipartFile profileImage) { if (memberRepository.existsByEmail(request.getEmail())) { throw new IllegalArgumentException("이미 가입된 이메일입니다."); } @@ -54,19 +57,16 @@ public void registerUser(SignUpRequestDto request, MultipartFile profileImage) { String encodedPassword = passwordEncoder.encode(request.getPassword()); - // 프로필 이미지 업로드 (비동기) - String profileImageUrl = "https://default-image-url.com/default-profile.png"; - if (profileImage != null && !profileImage.isEmpty()) { - try { - String fileName = "profile_images/" + request.getNickName() + "_" + System.currentTimeMillis(); - profileImageUrl = s3AsyncService.uploadFile(fileName, profileImage.getBytes(), profileImage.getContentType()).join(); - } catch (IOException e) { - throw new RuntimeException("파일 업로드 실패: " + e.getMessage()); - } - } + // 프로필 이미지 업로드 (비동기 처리) + String pathPrefix = "profile_images/" + request.getNickName(); + String defaultImageUrl = "https://default-image-url.com/default-profile.png"; + CompletableFuture profileImageUrlFuture = s3UploadUtil.uploadImageAsync(profileImage, pathPrefix, defaultImageUrl); - Member member = new Member(request.getEmail(), encodedPassword, request.getNickName(), profileImageUrl, request.getName()); - memberRepository.save(member); + // 업로드 완료 후 Member 저장 + return profileImageUrlFuture.thenAccept(profileImageUrl -> { + Member member = new Member(request.getEmail(), encodedPassword, request.getNickName(), profileImageUrl, request.getName()); + memberRepository.save(member); + }); } /** @@ -98,8 +98,7 @@ public Member findById(Long id) { /** * 회원 정보 수정 */ - @Transactional - public UpdateMemberResponseDto updateMember(Long memberId, UpdateMemberRequestDto request, MultipartFile newProfileImage) { + public CompletableFuture updateMember(Long memberId, UpdateMemberRequestDto request, MultipartFile newProfileImage) { Member member = memberRepository.findById(memberId) .orElseThrow(() -> new EntityNotFoundException("회원 정보를 찾을 수 없습니다.")); @@ -117,36 +116,42 @@ public UpdateMemberResponseDto updateMember(Long memberId, UpdateMemberRequestDt member.setName(request.getName()); } - // 프로필 이미지 변경 - if (newProfileImage != null && !newProfileImage.isEmpty()) { - try { - // 기존 프로필 이미지 삭제 - if (member.getProfileImageUrl() != null) { - String oldFileName = extractFileName(member.getProfileImageUrl()); - s3AsyncService.deleteFile("profile_images/" + oldFileName); - } - - String newFileName = "profile_images/" + member.getNickName() + "_" + System.currentTimeMillis(); - String contentType = newProfileImage.getContentType(); - byte[] fileData = newProfileImage.getBytes(); - - String newProfileImageUrl = s3AsyncService - .uploadFile(newFileName, fileData, contentType) - .join(); - - member.setProfileImageUrl(newProfileImageUrl); - } catch (IOException e) { - throw new RuntimeException("프로필 이미지 변경 실패: " + e.getMessage()); + 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 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) { diff --git a/src/main/java/pawparazzi/back/pet/controller/PetController.java b/src/main/java/pawparazzi/back/pet/controller/PetController.java index b2ebe4d..a5e4958 100644 --- a/src/main/java/pawparazzi/back/pet/controller/PetController.java +++ b/src/main/java/pawparazzi/back/pet/controller/PetController.java @@ -1,18 +1,22 @@ package pawparazzi.back.pet.controller; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.validation.Valid; 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 +24,70 @@ 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(pet -> ResponseEntity.ok(new PetResponseDto(pet))); } - //회원별 전체 펫 조회 + /** + * 회원별 반려동물 목록 조회 + */ @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( @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, + @RequestBody @Valid PetUpdateDto updateDto) { + + Long userId = jwtUtil.extractMemberId(token.replace("Bearer ", "")); + return ResponseEntity.ok(petService.updatePet(petId, userId, updateDto)); } - //반려동물 삭제 + /** + * 반려동물 삭제 + */ @DeleteMapping("/{petId}") - public ResponseEntity deletePet( - @PathVariable Long petId, - @RequestHeader("Authorization") String token){ - petService.deletePet(petId, token); - return ResponseEntity.noContent().build(); + public ResponseEntity> deletePet(@PathVariable Long petId, @RequestHeader("Authorization") String token) { + Long userId = jwtUtil.extractMemberId(token.replace("Bearer ", "")); + petService.deletePet(petId, userId); + return 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..e96542e 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) @@ -36,18 +35,18 @@ public class Pet { @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..e7c3198 100644 --- a/src/main/java/pawparazzi/back/pet/service/PetService.java +++ b/src/main/java/pawparazzi/back/pet/service/PetService.java @@ -1,20 +1,23 @@ 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.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 +26,87 @@ public class PetService { private final PetRepository petRepository; private final MemberRepository memberRepository; - private final JwtUtil jwtUtil; - - //펫 등록 + private final S3UploadUtil s3UploadUtil; + /** + * 반려동물 등록 + */ @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("사용자를 찾을 수 없습니다.")); + .orElseThrow(() -> new EntityNotFoundException("사용자를 찾을 수 없습니다.")); + + String pathPrefix = "pet_images/" + member.getNickName(); + String defaultImageUrl = "https://default-image-url.com/default-pet.png"; - Pet pet = new Pet(); - pet.setName(registerDto.getName()); - pet.setType(registerDto.getType()); - pet.setBirthDate(registerDto.getBirthDate()); - pet.setPetImg(registerDto.getPetImg()); - pet.setMember(member); + CompletableFuture petImageUrlFuture = s3UploadUtil.uploadImageAsync(petImage, pathPrefix, defaultImageUrl); - return petRepository.save(pet); + return petImageUrlFuture.thenApply(petImageUrl -> { + Pet pet = new Pet(registerDto.getName(), registerDto.getType(), registerDto.getBirthDate(), petImageUrl, member); + return petRepository.save(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); + .orElseThrow(() -> new EntityNotFoundException("사용자를 찾을 수 없습니다.")); - 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()); - } - - 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 PetResponseDto updatePet(Long petId, Long userId, PetUpdateDto updateDto) { + Pet pet = petRepository.findById(petId) + .orElseThrow(() -> new EntityNotFoundException("반려동물을 찾을 수 없습니다.")); + + if (!pet.getMember().getId().equals(userId)) { + throw new IllegalArgumentException("해당 반려동물을 수정할 권한이 없습니다."); + } - 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); + + return new PetResponseDto(petRepository.save(pet)); } + /** + * 반려동물 삭제 + */ @Transactional - public void deletePet(Long petId, String token) { - Long userId = jwtUtil.extractMemberId(token.replace("Bearer ", "")); + public void deletePet(Long petId, Long userId) { + Pet pet = petRepository.findById(petId) + .orElseThrow(() -> new EntityNotFoundException("반려동물을 찾을 수 없습니다.")); + + if (!pet.getMember().getId().equals(userId)) { + throw new IllegalArgumentException("해당 반려동물을 삭제할 권한이 없습니다."); + } - memberRepository.findById(userId) - .orElseThrow(() -> new IllegalArgumentException("사용자를 찾을 수 없습니다.")); - Pet pet = getPetById(token, petId); - pet.getMember().getPets().remove(pet); petRepository.delete(pet); } - -} +} \ 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 aaa0245..74b9710 100644 --- a/src/main/java/pawparazzi/back/security/config/SecurityConfig.java +++ b/src/main/java/pawparazzi/back/security/config/SecurityConfig.java @@ -35,9 +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() From 26fd78158e80c2dfc3a0531f6c6f59eedf596abe Mon Sep 17 00:00:00 2001 From: geg222 <147246023+geg222@users.noreply.github.com> Date: Fri, 14 Mar 2025 15:16:14 +0900 Subject: [PATCH 06/15] =?UTF-8?q?[FEAT]=20Pet=20=EC=88=98=EC=A0=95,=20?= =?UTF-8?q?=EC=82=AD=EC=A0=9C=20S3=20=EC=97=B0=EB=8F=99=20=EA=B8=B0?= =?UTF-8?q?=EB=8A=A5=20=EB=B0=8F=20=EC=8B=9C=EA=B0=81=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit [FEAT] Pet 수정, 삭제 S3 연동 기능 및 시각화 --- .../back/pet/controller/PetController.java | 32 ++++++-- .../java/pawparazzi/back/pet/entity/Pet.java | 2 +- .../back/pet/service/PetService.java | 73 ++++++++++++++++--- 3 files changed, 87 insertions(+), 20 deletions(-) diff --git a/src/main/java/pawparazzi/back/pet/controller/PetController.java b/src/main/java/pawparazzi/back/pet/controller/PetController.java index a5e4958..39ae513 100644 --- a/src/main/java/pawparazzi/back/pet/controller/PetController.java +++ b/src/main/java/pawparazzi/back/pet/controller/PetController.java @@ -46,7 +46,7 @@ public CompletableFuture> registerPet( } return petService.registerPet(userId, registerDto, petImage) - .thenApply(pet -> ResponseEntity.ok(new PetResponseDto(pet))); + .thenApply(ResponseEntity::ok); } /** @@ -71,23 +71,39 @@ public ResponseEntity getPet(@PathVariable Long petId, @RequestH /** * 반려동물 정보 수정 */ - @PutMapping("/{petId}") - public ResponseEntity updatePet( + @PatchMapping(value = "/{petId}", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + public CompletableFuture> updatePet( @PathVariable Long petId, @RequestHeader("Authorization") String token, - @RequestBody @Valid PetUpdateDto updateDto) { + @RequestPart(value = "petData", required = false) String petDataJson, + @RequestPart(value = "petImage", required = false) MultipartFile petImage) { Long userId = jwtUtil.extractMemberId(token.replace("Bearer ", "")); - return ResponseEntity.ok(petService.updatePet(petId, userId, updateDto)); + + 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(@PathVariable Long petId, @RequestHeader("Authorization") String token) { + public CompletableFuture>> deletePet( + @PathVariable Long petId, + @RequestHeader("Authorization") String token) { + Long userId = jwtUtil.extractMemberId(token.replace("Bearer ", "")); - petService.deletePet(petId, userId); - return ResponseEntity.ok(Map.of("message", "반려동물이 삭제되었습니다.")); + + 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/entity/Pet.java b/src/main/java/pawparazzi/back/pet/entity/Pet.java index e96542e..3e132e1 100644 --- a/src/main/java/pawparazzi/back/pet/entity/Pet.java +++ b/src/main/java/pawparazzi/back/pet/entity/Pet.java @@ -31,7 +31,7 @@ public class Pet { private String petImg; @JsonIgnore - @ManyToOne(fetch = FetchType.LAZY) + @ManyToOne(fetch = FetchType.EAGER) @JoinColumn(name = "user_id") private 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 e7c3198..03e9e25 100644 --- a/src/main/java/pawparazzi/back/pet/service/PetService.java +++ b/src/main/java/pawparazzi/back/pet/service/PetService.java @@ -7,6 +7,7 @@ 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.pet.dto.PetRegisterRequestDto; @@ -27,23 +28,27 @@ public class PetService { private final PetRepository petRepository; private final MemberRepository memberRepository; private final S3UploadUtil s3UploadUtil; + private final S3AsyncService s3AsyncService; /** * 반려동물 등록 */ @Transactional - public CompletableFuture registerPet(Long userId, PetRegisterRequestDto registerDto, MultipartFile petImage) { + public CompletableFuture registerPet(Long userId, PetRegisterRequestDto registerDto, MultipartFile petImage) { Member member = memberRepository.findById(userId) - .orElseThrow(() -> new EntityNotFoundException("사용자를 찾을 수 없습니다.")); + .orElseThrow(() -> new IllegalArgumentException("사용자를 찾을 수 없습니다.")); String pathPrefix = "pet_images/" + member.getNickName(); String defaultImageUrl = "https://default-image-url.com/default-pet.png"; + // S3 이미지 업로드 (비동기 처리) CompletableFuture petImageUrlFuture = s3UploadUtil.uploadImageAsync(petImage, pathPrefix, defaultImageUrl); return petImageUrlFuture.thenApply(petImageUrl -> { Pet pet = new Pet(registerDto.getName(), registerDto.getType(), registerDto.getBirthDate(), petImageUrl, member); - return petRepository.save(pet); + member.addPet(pet); // Member에 반려동물 추가 + petRepository.save(pet); + return new PetResponseDto(pet); }); } @@ -79,7 +84,7 @@ public PetResponseDto getPetById(Long petId, Long userId) { * 반려동물 정보 수정 */ @Transactional - public PetResponseDto updatePet(Long petId, Long userId, PetUpdateDto updateDto) { + public CompletableFuture updatePet(Long petId, Long userId, PetUpdateDto updateDto, MultipartFile petImage) { Pet pet = petRepository.findById(petId) .orElseThrow(() -> new EntityNotFoundException("반려동물을 찾을 수 없습니다.")); @@ -87,19 +92,37 @@ public PetResponseDto updatePet(Long petId, Long userId, PetUpdateDto updateDto) throw new IllegalArgumentException("해당 반려동물을 수정할 권한이 없습니다."); } - pet.setName(updateDto.getName()); - pet.setType(updateDto.getType()); - pet.setBirthDate(updateDto.getBirthDate()); - pet.setPetImg(updateDto.getPetImg()); - - return new PetResponseDto(petRepository.save(pet)); + 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, Long userId) { + public CompletableFuture deletePet(Long petId, Long userId) { Pet pet = petRepository.findById(petId) .orElseThrow(() -> new EntityNotFoundException("반려동물을 찾을 수 없습니다.")); @@ -107,6 +130,34 @@ public void deletePet(Long petId, Long 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 From 55d68cf8f3ac9ee9c6387ba449d3582a0e0b5101 Mon Sep 17 00:00:00 2001 From: hanjunLee00 Date: Sun, 16 Mar 2025 11:58:49 +0900 Subject: [PATCH 07/15] =?UTF-8?q?feat:=20=EC=82=B0=EC=B1=85=EA=B8=B0?= =?UTF-8?q?=EB=A1=9D=20=EC=A0=80=EC=9E=A5/=EC=A1=B0=ED=9A=8C/=EC=82=AD?= =?UTF-8?q?=EC=A0=9C=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 산책기록 저장 - 산책기록 조회 - 산책기록 삭제 - 펫별 산책기록 조회 - 날짜별 산책기록 조회 (캘린더용) --- .../back/walk/controller/WalkController.java | 93 ++++++++++++++++++ .../back/walk/dto/LocationPointDto.java | 20 ++++ .../back/walk/dto/WalkRequestDto.java | 25 +++++ .../back/walk/dto/WalkResponseDto.java | 50 ++++++++++ .../back/walk/entity/LocationPoint.java | 35 +++++++ .../pawparazzi/back/walk/entity/Walk.java | 46 +++++++++ .../back/walk/entity/WalkMapper.java | 63 +++++++++++++ .../back/walk/repository/WalkRepository.java | 21 +++++ .../back/walk/service/WalkService.java | 94 +++++++++++++++++++ 9 files changed, 447 insertions(+) create mode 100644 src/main/java/pawparazzi/back/walk/controller/WalkController.java create mode 100644 src/main/java/pawparazzi/back/walk/dto/LocationPointDto.java create mode 100644 src/main/java/pawparazzi/back/walk/dto/WalkRequestDto.java create mode 100644 src/main/java/pawparazzi/back/walk/dto/WalkResponseDto.java create mode 100644 src/main/java/pawparazzi/back/walk/entity/LocationPoint.java create mode 100644 src/main/java/pawparazzi/back/walk/entity/Walk.java create mode 100644 src/main/java/pawparazzi/back/walk/entity/WalkMapper.java create mode 100644 src/main/java/pawparazzi/back/walk/repository/WalkRepository.java create mode 100644 src/main/java/pawparazzi/back/walk/service/WalkService.java 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..efac69d --- /dev/null +++ b/src/main/java/pawparazzi/back/walk/controller/WalkController.java @@ -0,0 +1,93 @@ +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.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) ZonedDateTime 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(); + } + } +} \ 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..63f2be5 --- /dev/null +++ b/src/main/java/pawparazzi/back/walk/dto/LocationPointDto.java @@ -0,0 +1,20 @@ +package pawparazzi.back.walk.dto; + +import com.fasterxml.jackson.annotation.JsonFormat; +import lombok.*; + +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:ssXXX", timezone = "UTC") + private ZonedDateTime 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..cb45688 --- /dev/null +++ b/src/main/java/pawparazzi/back/walk/dto/WalkRequestDto.java @@ -0,0 +1,25 @@ +package pawparazzi.back.walk.dto; + +import com.fasterxml.jackson.annotation.JsonFormat; +import lombok.Data; +import lombok.Getter; + +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:ssXXX", timezone = "UTC") + private ZonedDateTime startTime; + + @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd'T'HH:mm:ssXXX", timezone = "UTC") + private ZonedDateTime 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..bfbb1e8 --- /dev/null +++ b/src/main/java/pawparazzi/back/walk/dto/WalkResponseDto.java @@ -0,0 +1,50 @@ +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.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:ssXXX", timezone = "UTC") + private ZonedDateTime startTime; + + @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd'T'HH:mm:ssXXX", timezone = "UTC") + private ZonedDateTime 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, ZonedDateTime startTime, ZonedDateTime 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..89a59b5 --- /dev/null +++ b/src/main/java/pawparazzi/back/walk/entity/LocationPoint.java @@ -0,0 +1,35 @@ +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.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 ZonedDateTime 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..7e2cbcd --- /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 ZonedDateTime startTime; + + @Column(name = "end_time", nullable = false) + private ZonedDateTime 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..ecd17f2 --- /dev/null +++ b/src/main/java/pawparazzi/back/walk/repository/WalkRepository.java @@ -0,0 +1,21 @@ +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.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") ZonedDateTime 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..01f7511 --- /dev/null +++ b/src/main/java/pawparazzi/back/walk/service/WalkService.java @@ -0,0 +1,94 @@ +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.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(ZonedDateTime 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()); + } +} \ No newline at end of file From 6f220b634a3a3e212f6a6f90e5d8797b290ce7e5 Mon Sep 17 00:00:00 2001 From: hanjunLee00 Date: Sun, 16 Mar 2025 13:28:43 +0900 Subject: [PATCH 08/15] =?UTF-8?q?feat:=20=EB=82=A0=EC=A7=9C=EC=99=80=20?= =?UTF-8?q?=ED=8E=AB=20=EC=95=84=EC=9D=B4=EB=94=94=EB=A1=9C=20=EC=82=B0?= =?UTF-8?q?=EC=B1=85=20=EA=B8=B0=EB=A1=9D=20=EC=A1=B0=ED=9A=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 날짜와 펫 아이디로 산책 기록 반환 --- .../back/walk/controller/WalkController.java | 15 +++++++++++++++ .../back/walk/repository/WalkRepository.java | 4 ++++ .../back/walk/service/WalkService.java | 18 ++++++++++++++++++ 3 files changed, 37 insertions(+) diff --git a/src/main/java/pawparazzi/back/walk/controller/WalkController.java b/src/main/java/pawparazzi/back/walk/controller/WalkController.java index efac69d..168b2cf 100644 --- a/src/main/java/pawparazzi/back/walk/controller/WalkController.java +++ b/src/main/java/pawparazzi/back/walk/controller/WalkController.java @@ -90,4 +90,19 @@ public ResponseEntity> getWalkByPetDate( return ResponseEntity.notFound().build(); } } + + //날짜와 펫별 산책 기록 조회 + @GetMapping("/pet/{petId}/date") + public ResponseEntity> getWalkByPetAndDate( + @PathVariable Long petId, + @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) ZonedDateTime 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/repository/WalkRepository.java b/src/main/java/pawparazzi/back/walk/repository/WalkRepository.java index ecd17f2..88ae5cc 100644 --- a/src/main/java/pawparazzi/back/walk/repository/WalkRepository.java +++ b/src/main/java/pawparazzi/back/walk/repository/WalkRepository.java @@ -18,4 +18,8 @@ public interface WalkRepository extends JpaRepository { // 특정 날짜의 산책 조회 (날짜만 비교) @Query("SELECT w FROM Walk w WHERE FUNCTION('DATE', w.startTime) = FUNCTION('DATE', :date) ORDER BY w.startTime DESC") List findByDate(@Param("date") ZonedDateTime 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") ZonedDateTime date); } diff --git a/src/main/java/pawparazzi/back/walk/service/WalkService.java b/src/main/java/pawparazzi/back/walk/service/WalkService.java index 01f7511..79ab1bf 100644 --- a/src/main/java/pawparazzi/back/walk/service/WalkService.java +++ b/src/main/java/pawparazzi/back/walk/service/WalkService.java @@ -91,4 +91,22 @@ public List getWalksByPetId(Long petId, Long userId){ .map(walkMapper::toDto) .collect(Collectors.toList()); } + + @Transactional(readOnly = true) + public List getWalksByPetIdAndDate(Long petId, ZonedDateTime 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 From 55951fa3e501fd758d6267db0705b118f8538928 Mon Sep 17 00:00:00 2001 From: geg222 <147246023+geg222@users.noreply.github.com> Date: Sun, 16 Mar 2025 16:10:35 +0900 Subject: [PATCH 09/15] =?UTF-8?q?[FIX]=20kakao=20=EC=98=A4=EB=A5=98=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit [FIX] kakao 오류 수정 --- .../pawparazzi/back/member/controller/AuthController.java | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) 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()); From ac7e2d7d17714eef7fd2020aa4af8c33c62d7520 Mon Sep 17 00:00:00 2001 From: geg222 <147246023+geg222@users.noreply.github.com> Date: Sun, 16 Mar 2025 16:10:35 +0900 Subject: [PATCH 10/15] =?UTF-8?q?[FIX]=20kakao=20=EC=98=A4=EB=A5=98=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit [FIX] kakao 오류 수정 --- .../pawparazzi/back/member/controller/AuthController.java | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) 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()); From fceb123100798e755a268471fc11e72a4457bfd6 Mon Sep 17 00:00:00 2001 From: hanjunLee00 Date: Wed, 19 Mar 2025 18:22:20 +0900 Subject: [PATCH 11/15] =?UTF-8?q?refactor:=20=EB=82=A0=EC=A7=9C=20?= =?UTF-8?q?=ED=98=95=EC=8B=9D=20ZonedDateTime=EC=97=90=EC=84=9C=20LocalDat?= =?UTF-8?q?eTime=EC=9C=BC=EB=A1=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Dto, Entity 모두 LocalDateTime으로 변경 --- .../back/walk/controller/WalkController.java | 5 +++-- .../pawparazzi/back/walk/dto/LocationPointDto.java | 5 +++-- .../java/pawparazzi/back/walk/dto/WalkRequestDto.java | 9 +++++---- .../pawparazzi/back/walk/dto/WalkResponseDto.java | 11 ++++++----- .../pawparazzi/back/walk/entity/LocationPoint.java | 3 ++- src/main/java/pawparazzi/back/walk/entity/Walk.java | 4 ++-- .../back/walk/repository/WalkRepository.java | 5 +++-- .../pawparazzi/back/walk/service/WalkService.java | 5 +++-- 8 files changed, 27 insertions(+), 20 deletions(-) diff --git a/src/main/java/pawparazzi/back/walk/controller/WalkController.java b/src/main/java/pawparazzi/back/walk/controller/WalkController.java index 168b2cf..4be6668 100644 --- a/src/main/java/pawparazzi/back/walk/controller/WalkController.java +++ b/src/main/java/pawparazzi/back/walk/controller/WalkController.java @@ -12,6 +12,7 @@ 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; @@ -80,7 +81,7 @@ public ResponseEntity> getWalkByPet( //날짜별 산책 기록 조회 @GetMapping("/date") public ResponseEntity> getWalkByPetDate( - @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) ZonedDateTime date, + @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime date, @RequestHeader("Authorization") String token){ try{ Long userId = jwtUtil.extractMemberId(token.replace("Bearer ", "")); @@ -95,7 +96,7 @@ public ResponseEntity> getWalkByPetDate( @GetMapping("/pet/{petId}/date") public ResponseEntity> getWalkByPetAndDate( @PathVariable Long petId, - @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) ZonedDateTime date, + @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime date, @RequestHeader("Authorization") String token) { try { Long userId = jwtUtil.extractMemberId(token.replace("Bearer ", "")); diff --git a/src/main/java/pawparazzi/back/walk/dto/LocationPointDto.java b/src/main/java/pawparazzi/back/walk/dto/LocationPointDto.java index 63f2be5..f8c68bd 100644 --- a/src/main/java/pawparazzi/back/walk/dto/LocationPointDto.java +++ b/src/main/java/pawparazzi/back/walk/dto/LocationPointDto.java @@ -3,6 +3,7 @@ import com.fasterxml.jackson.annotation.JsonFormat; import lombok.*; +import java.time.LocalDateTime; import java.time.ZonedDateTime; @Getter @@ -14,7 +15,7 @@ public class LocationPointDto { private Double longitude; - @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd'T'HH:mm:ssXXX", timezone = "UTC") - private ZonedDateTime timestamp; + @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 index cb45688..f6804ab 100644 --- a/src/main/java/pawparazzi/back/walk/dto/WalkRequestDto.java +++ b/src/main/java/pawparazzi/back/walk/dto/WalkRequestDto.java @@ -4,6 +4,7 @@ import lombok.Data; import lombok.Getter; +import java.time.LocalDateTime; import java.time.ZonedDateTime; import java.util.List; @@ -11,11 +12,11 @@ public class WalkRequestDto { private Long petId; - @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd'T'HH:mm:ssXXX", timezone = "UTC") - private ZonedDateTime startTime; + @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:ssXXX", timezone = "UTC") - private ZonedDateTime endTime; + @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd'T'HH:mm:ss") + private LocalDateTime endTime; private List route; diff --git a/src/main/java/pawparazzi/back/walk/dto/WalkResponseDto.java b/src/main/java/pawparazzi/back/walk/dto/WalkResponseDto.java index bfbb1e8..8d733cc 100644 --- a/src/main/java/pawparazzi/back/walk/dto/WalkResponseDto.java +++ b/src/main/java/pawparazzi/back/walk/dto/WalkResponseDto.java @@ -5,6 +5,7 @@ import pawparazzi.back.pet.entity.Pet; import pawparazzi.back.pet.entity.Type; +import java.time.LocalDateTime; import java.time.ZonedDateTime; import java.util.List; @@ -13,11 +14,11 @@ public class WalkResponseDto { private Long id; private PetDto pet; - @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd'T'HH:mm:ssXXX", timezone = "UTC") - private ZonedDateTime startTime; + @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:ssXXX", timezone = "UTC") - private ZonedDateTime endTime; + @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd'T'HH:mm:ss") + private LocalDateTime endTime; private List route; private Double distance; @@ -38,7 +39,7 @@ public PetDto(Pet pet) { } } - public WalkResponseDto(Long id, Pet pet, ZonedDateTime startTime, ZonedDateTime endTime, List route, Double distance, Double averageSpeed) { + 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; diff --git a/src/main/java/pawparazzi/back/walk/entity/LocationPoint.java b/src/main/java/pawparazzi/back/walk/entity/LocationPoint.java index 89a59b5..68f2bc8 100644 --- a/src/main/java/pawparazzi/back/walk/entity/LocationPoint.java +++ b/src/main/java/pawparazzi/back/walk/entity/LocationPoint.java @@ -7,6 +7,7 @@ import lombok.Setter; import pawparazzi.back.pet.entity.Pet; +import java.time.LocalDateTime; import java.time.ZonedDateTime; @Entity @@ -27,7 +28,7 @@ public class LocationPoint { private Double longitude; @Column(nullable = false) - private ZonedDateTime timestamp; + private LocalDateTime timestamp; @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "walk_id") diff --git a/src/main/java/pawparazzi/back/walk/entity/Walk.java b/src/main/java/pawparazzi/back/walk/entity/Walk.java index 7e2cbcd..9df86d4 100644 --- a/src/main/java/pawparazzi/back/walk/entity/Walk.java +++ b/src/main/java/pawparazzi/back/walk/entity/Walk.java @@ -30,10 +30,10 @@ public class Walk { private Pet pet; @Column(name = "start_time", nullable = false) - private ZonedDateTime startTime; + private LocalDateTime startTime; @Column(name = "end_time", nullable = false) - private ZonedDateTime endTime; + private LocalDateTime endTime; @Column(nullable = false) private Double distance; diff --git a/src/main/java/pawparazzi/back/walk/repository/WalkRepository.java b/src/main/java/pawparazzi/back/walk/repository/WalkRepository.java index 88ae5cc..2320bfd 100644 --- a/src/main/java/pawparazzi/back/walk/repository/WalkRepository.java +++ b/src/main/java/pawparazzi/back/walk/repository/WalkRepository.java @@ -6,6 +6,7 @@ import org.springframework.stereotype.Repository; import pawparazzi.back.walk.entity.Walk; +import java.time.LocalDateTime; import java.time.ZonedDateTime; import java.util.List; @@ -17,9 +18,9 @@ public interface WalkRepository extends JpaRepository { // 특정 날짜의 산책 조회 (날짜만 비교) @Query("SELECT w FROM Walk w WHERE FUNCTION('DATE', w.startTime) = FUNCTION('DATE', :date) ORDER BY w.startTime DESC") - List findByDate(@Param("date") ZonedDateTime date); + 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") ZonedDateTime date); + 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 index 79ab1bf..7a0ef21 100644 --- a/src/main/java/pawparazzi/back/walk/service/WalkService.java +++ b/src/main/java/pawparazzi/back/walk/service/WalkService.java @@ -11,6 +11,7 @@ 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; @@ -64,7 +65,7 @@ public void deleteWalk(Long walkId, Long userId){ } @Transactional(readOnly = true) - public List getWalksByDate(ZonedDateTime date, Long userId) { + public List getWalksByDate(LocalDateTime date, Long userId) { List walks = walkRepository.findByDate(date); // userId로 필터링: 해당 사용자의 반려동물 산책 기록만 반환 @@ -93,7 +94,7 @@ public List getWalksByPetId(Long petId, Long userId){ } @Transactional(readOnly = true) - public List getWalksByPetIdAndDate(Long petId, ZonedDateTime date, Long userId) { + public List getWalksByPetIdAndDate(Long petId, LocalDateTime date, Long userId) { Pet pet = petRepository.findById(petId) .orElseThrow(() -> new NoSuchElementException("Pet not found with Id: " + petId)); From c210dbf7eba4885fede51f8522bfa53a7c16e967 Mon Sep 17 00:00:00 2001 From: geg222 Date: Thu, 20 Mar 2025 17:30:21 +0900 Subject: [PATCH 12/15] =?UTF-8?q?[FEAT]=20=EA=B2=8C=EC=8B=9C=EB=AC=BC=20S3?= =?UTF-8?q?=20=EA=B8=B0=EB=8A=A5=20=EC=97=B0=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit [FEAT] 게시물 S3 기능 연동 --- .../java/pawparazzi/back/S3/S3UploadUtil.java | 2 + .../back/S3/service/S3AsyncService.java | 76 +++++-- .../board/controller/BoardController.java | 61 ++++-- .../back/board/dto/BoardCreateRequestDto.java | 7 +- .../back/board/dto/BoardUpdateRequestDto.java | 4 + .../back/board/service/BoardService.java | 194 ++++++++++++------ .../back/pet/controller/PetController.java | 1 - .../back/pet/service/PetService.java | 2 +- 8 files changed, 256 insertions(+), 91 deletions(-) diff --git a/src/main/java/pawparazzi/back/S3/S3UploadUtil.java b/src/main/java/pawparazzi/back/S3/S3UploadUtil.java index 74703ce..b347cea 100644 --- a/src/main/java/pawparazzi/back/S3/S3UploadUtil.java +++ b/src/main/java/pawparazzi/back/S3/S3UploadUtil.java @@ -1,5 +1,6 @@ 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; @@ -16,6 +17,7 @@ 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); diff --git a/src/main/java/pawparazzi/back/S3/service/S3AsyncService.java b/src/main/java/pawparazzi/back/S3/service/S3AsyncService.java index f213742..7edde09 100644 --- a/src/main/java/pawparazzi/back/S3/service/S3AsyncService.java +++ b/src/main/java/pawparazzi/back/S3/service/S3AsyncService.java @@ -5,11 +5,12 @@ 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.PutObjectRequest; -import software.amazon.awssdk.services.s3.model.DeleteObjectRequest; +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 { @@ -46,18 +47,35 @@ public CompletableFuture uploadFile(String fileName, byte[] fileData, St /** * S3에서 비동기적으로 파일 삭제 */ - @Async - public CompletableFuture deleteFile(String fileName) { - DeleteObjectRequest deleteObjectRequest = DeleteObjectRequest.builder() - .bucket(bucketName) - .key(fileName) - .build(); + 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()); - return s3AsyncClient.deleteObject(deleteObjectRequest) - .thenAccept(response -> { - if (!response.sdkHttpResponse().isSuccessful()) { - throw new RuntimeException("S3 삭제 실패: " + response.sdkHttpResponse().statusCode()); + 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; + }); }); } @@ -78,4 +96,38 @@ public CompletableFuture updateProfileImage(String existingImageUrl, Str 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..ce9faad 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; @@ -21,15 +25,29 @@ public class BoardController { private final JwtUtil jwtUtil; /** - * 게시글 등록 + * 게시물 등록 */ - @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); + + ObjectMapper objectMapper = new ObjectMapper(); + 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,29 @@ 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 ", "")); + + ObjectMapper objectMapper = new ObjectMapper(); + 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 +110,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/pet/controller/PetController.java b/src/main/java/pawparazzi/back/pet/controller/PetController.java index 39ae513..8f52f10 100644 --- a/src/main/java/pawparazzi/back/pet/controller/PetController.java +++ b/src/main/java/pawparazzi/back/pet/controller/PetController.java @@ -2,7 +2,6 @@ import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; -import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; diff --git a/src/main/java/pawparazzi/back/pet/service/PetService.java b/src/main/java/pawparazzi/back/pet/service/PetService.java index 03e9e25..4d558e8 100644 --- a/src/main/java/pawparazzi/back/pet/service/PetService.java +++ b/src/main/java/pawparazzi/back/pet/service/PetService.java @@ -136,7 +136,7 @@ public CompletableFuture deletePet(Long petId, Long userId) { // 먼저 DB에서 반려동물 삭제 petRepository.delete(pet); - // S3에서 기존 반려동물 이미지 삭제 (기본 이미지가 아닌 경우) + // S3에서 기존 반려동물 이미지 삭제 if (petImageUrl != null && !petImageUrl.equals(defaultImageUrl)) { String fileName = extractFileName(petImageUrl); return s3AsyncService.deleteFile("pet_images/" + fileName) From b2ce1e64853d4d3ccceffec2edf9a92b1385ac0f Mon Sep 17 00:00:00 2001 From: hanjunLee00 Date: Sun, 23 Mar 2025 21:47:32 +0900 Subject: [PATCH 13/15] =?UTF-8?q?feat:=20Github=20Actions=EC=97=90=20deplo?= =?UTF-8?q?y.yml=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/deploy.yml | 43 ++++++++++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) create mode 100644 .github/workflows/deploy.yml diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 0000000..6735f69 --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,43 @@ +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 + ./deploy.sh \ No newline at end of file From cbe8739f073eb130456765c97dfa22b5bb26f935 Mon Sep 17 00:00:00 2001 From: hanjunLee00 Date: Mon, 24 Mar 2025 15:42:00 +0900 Subject: [PATCH 14/15] =?UTF-8?q?fix:=20deploy.yml=EC=97=90=20=EC=8B=A4?= =?UTF-8?q?=ED=96=89=20=EA=B6=8C=ED=95=9C=20=EC=8A=A4=ED=81=AC=EB=A6=BD?= =?UTF-8?q?=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - deploy.sh에 실행 권한 추가 --- .github/workflows/deploy.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 6735f69..b8e3de2 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -40,4 +40,5 @@ jobs: key: ${{ secrets.EC2_SSH_KEY }} script: | cd /home/ubuntu/deploy + chmod +x deploy.sh ./deploy.sh \ No newline at end of file From 286835cb4f9810fa07ff34ca2f8fb1879cf02813 Mon Sep 17 00:00:00 2001 From: geg222 Date: Mon, 24 Mar 2025 18:52:52 +0900 Subject: [PATCH 15/15] =?UTF-8?q?[Refactor]=20borad=20=EB=B6=88=ED=95=84?= =?UTF-8?q?=EC=9A=94=ED=95=9C=20=EC=83=9D=EC=84=B1=EC=9E=90=20=EC=A0=9C?= =?UTF-8?q?=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit [Refactor] borad 불필요한 생성자 제거 --- .../java/pawparazzi/back/board/controller/BoardController.java | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/main/java/pawparazzi/back/board/controller/BoardController.java b/src/main/java/pawparazzi/back/board/controller/BoardController.java index ce9faad..22b8d89 100644 --- a/src/main/java/pawparazzi/back/board/controller/BoardController.java +++ b/src/main/java/pawparazzi/back/board/controller/BoardController.java @@ -23,6 +23,7 @@ public class BoardController { private final BoardService boardService; private final JwtUtil jwtUtil; + private final ObjectMapper objectMapper; /** * 게시물 등록 @@ -37,7 +38,6 @@ public ResponseEntity createBoard( Long memberId = jwtUtil.extractMemberId(token.replace("Bearer ", "")); - ObjectMapper objectMapper = new ObjectMapper(); BoardCreateRequestDto requestDto; try { requestDto = objectMapper.readValue(userDataJson, BoardCreateRequestDto.class); @@ -84,7 +84,6 @@ public ResponseEntity updateBoard( try { Long memberId = jwtUtil.extractMemberId(token.replace("Bearer ", "")); - ObjectMapper objectMapper = new ObjectMapper(); BoardUpdateRequestDto requestDto = objectMapper.readValue(userDataJson, BoardUpdateRequestDto.class); requestDto.setTitleContent(titleContent);