From 01606bc9820703d3e4e59596c0975278b3b72f66 Mon Sep 17 00:00:00 2001 From: rud15dns Date: Thu, 5 Feb 2026 21:31:12 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EC=82=AC=EC=A7=84=20=EC=A0=80=EC=9E=A5?= =?UTF-8?q?=20controller=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../member/controller/ProfileController.java | 46 +++++++++++++ .../domain/member/dto/ProfileReqDTO.java | 5 ++ .../domain/member/dto/ProfileResDTO.java | 6 ++ .../member/exception/MemberErrorStatus.java | 1 + .../repository/MemberPhotoRepository.java | 11 ++++ .../server/global/s3/S3Controller.java | 31 --------- .../meetkey/server/global/s3/S3ResDTO.java | 11 ---- .../meetkey/server/global/s3/S3Service.java | 64 +++++++++++++++---- 8 files changed, 122 insertions(+), 53 deletions(-) create mode 100644 src/main/java/com/meetkey/server/domain/member/repository/MemberPhotoRepository.java delete mode 100644 src/main/java/com/meetkey/server/global/s3/S3Controller.java delete mode 100644 src/main/java/com/meetkey/server/global/s3/S3ResDTO.java diff --git a/src/main/java/com/meetkey/server/domain/member/controller/ProfileController.java b/src/main/java/com/meetkey/server/domain/member/controller/ProfileController.java index bce50a9..cc0ce60 100644 --- a/src/main/java/com/meetkey/server/domain/member/controller/ProfileController.java +++ b/src/main/java/com/meetkey/server/domain/member/controller/ProfileController.java @@ -1,8 +1,12 @@ package com.meetkey.server.domain.member.controller; +import com.meetkey.server.domain.member.dto.ProfileReqDTO; +import com.meetkey.server.domain.member.dto.ProfileResDTO; +import com.meetkey.server.domain.member.entity.mapping.MemberPhoto; import com.meetkey.server.domain.member.service.ProfileService; import com.meetkey.server.global.apiPayload.response.BasicResponse; import com.meetkey.server.global.apiPayload.status.CommonSuccessStatus; +import com.meetkey.server.global.s3.S3Service; import com.meetkey.server.global.security.CustomUserDetails; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; @@ -16,6 +20,9 @@ import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; +import java.util.List; +import java.util.stream.Collectors; + import static com.meetkey.server.domain.member.dto.ProfileReqDTO.*; import static com.meetkey.server.domain.member.dto.ProfileResDTO.*; @@ -26,6 +33,7 @@ public class ProfileController { private final ProfileService profileService; + private final S3Service s3Service; @Operation(summary = "프로필 정보 수정 API", description = "사용자의 활동 지역(문자열), 한줄 소개, 언어 정보를 변경합니다.") @ApiResponses(value = { @@ -190,5 +198,43 @@ public ResponseEntity> toggleEvaluation( .ok() .body(BasicResponse.success(CommonSuccessStatus._OK, "평가가 반영되었습니다.")); } + + @Operation(summary = "업로드용 Presigned Url 발급 API", description = "프론트에서 파일 업로드 전에 요청") + @PostMapping("/photos") + public BasicResponse> getMemberPhotoUploadUrl( + @AuthenticationPrincipal CustomUserDetails customUserDetails, + @RequestBody List photoInfos + ){ + Long memberId = customUserDetails.getMemberId(); + + List responses = photoInfos.stream() + .map(info -> s3Service.generateMemberPhotoPresignedUrl(memberId, info.fileName(), info.contentType())) + .collect(Collectors.toList()); + + return BasicResponse.success(CommonSuccessStatus._OK, responses); + } + + @PostMapping("/photos/register") + public BasicResponse registerMemberPhotos( + @AuthenticationPrincipal CustomUserDetails customUserDetails, + @RequestBody List s3Keys // 프론트가 업로드 성공 후 보낸 key 리스트 + ) { + // 2. 새로운 s3Keys들을 MemberPhoto 엔티티로 만들어 저장 + Long memberId = customUserDetails.getMemberId(); + s3Service.registerMemberPhotoKeys(memberId, s3Keys); + + return BasicResponse.success(CommonSuccessStatus._OK, null); + } + + @Operation(summary = "내 프로필 사진 조회 API", description = "로그인한 사용자의 프로필 사진 URL 리스트를 가져옵니다.") + @GetMapping("/photos") + public BasicResponse> getMyPhotos( + @AuthenticationPrincipal CustomUserDetails customUserDetails + ) { + Long memberId = customUserDetails.getMemberId(); + List photoUrls = s3Service.getMemberPhotoUrls(memberId); + + return BasicResponse.success(CommonSuccessStatus._OK, photoUrls); + } } diff --git a/src/main/java/com/meetkey/server/domain/member/dto/ProfileReqDTO.java b/src/main/java/com/meetkey/server/domain/member/dto/ProfileReqDTO.java index b2984dc..c049f69 100644 --- a/src/main/java/com/meetkey/server/domain/member/dto/ProfileReqDTO.java +++ b/src/main/java/com/meetkey/server/domain/member/dto/ProfileReqDTO.java @@ -77,4 +77,9 @@ public record LocationUpdateRequest( @Schema(description = "경도", example = "126.9410") Double longitude ) {} + + public record PhotoInfo( + String fileName, + String contentType + ){} } diff --git a/src/main/java/com/meetkey/server/domain/member/dto/ProfileResDTO.java b/src/main/java/com/meetkey/server/domain/member/dto/ProfileResDTO.java index bef08b8..31894b2 100644 --- a/src/main/java/com/meetkey/server/domain/member/dto/ProfileResDTO.java +++ b/src/main/java/com/meetkey/server/domain/member/dto/ProfileResDTO.java @@ -116,4 +116,10 @@ public record OtherProfileResponse( String bio ) {} + @Builder + public record MemberPhotoUrl( + String url, + String key + ){} + } diff --git a/src/main/java/com/meetkey/server/domain/member/exception/MemberErrorStatus.java b/src/main/java/com/meetkey/server/domain/member/exception/MemberErrorStatus.java index 1716c70..5d875bf 100644 --- a/src/main/java/com/meetkey/server/domain/member/exception/MemberErrorStatus.java +++ b/src/main/java/com/meetkey/server/domain/member/exception/MemberErrorStatus.java @@ -17,6 +17,7 @@ public enum MemberErrorStatus implements BaseCode { // 회원 관련 MEMBER_NOT_FOUND(HttpStatus.NOT_FOUND, "MEMBER4041", "해당 사용자를 찾을 수 없습니다."), + INVALID_S3_KEY(HttpStatus.NOT_FOUND, "MEMBER4042", "저장된 프로필 사진을 찾을 수 없습니다."); ; private final HttpStatus status; diff --git a/src/main/java/com/meetkey/server/domain/member/repository/MemberPhotoRepository.java b/src/main/java/com/meetkey/server/domain/member/repository/MemberPhotoRepository.java new file mode 100644 index 0000000..d2118e2 --- /dev/null +++ b/src/main/java/com/meetkey/server/domain/member/repository/MemberPhotoRepository.java @@ -0,0 +1,11 @@ +package com.meetkey.server.domain.member.repository; + +import com.meetkey.server.domain.member.entity.Member; +import com.meetkey.server.domain.member.entity.mapping.MemberPhoto; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +public interface MemberPhotoRepository extends JpaRepository { + List findAllByMember(Member member); +} diff --git a/src/main/java/com/meetkey/server/global/s3/S3Controller.java b/src/main/java/com/meetkey/server/global/s3/S3Controller.java deleted file mode 100644 index 9fe1e8c..0000000 --- a/src/main/java/com/meetkey/server/global/s3/S3Controller.java +++ /dev/null @@ -1,31 +0,0 @@ -package com.meetkey.server.global.s3; - -import com.meetkey.server.global.apiPayload.response.BasicResponse; -import com.meetkey.server.global.apiPayload.status.CommonSuccessStatus; -import com.meetkey.server.global.security.CustomUserDetails; -import io.swagger.v3.oas.annotations.Operation; -import lombok.RequiredArgsConstructor; -import org.springframework.http.ResponseEntity; -import org.springframework.security.core.annotation.AuthenticationPrincipal; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RequestParam; -import org.springframework.web.bind.annotation.RestController; - -@RestController -@RequiredArgsConstructor -@RequestMapping("/s3") -public class S3Controller { - private final S3Service s3Service; - - @Operation(summary = "업로드용 Presigned Url 발급 API", description = "프론트에서 파일 업로드 전에 요청") - @GetMapping("/presigned-upload") - public BasicResponse getPresignedUploadUrl( - @RequestParam String folder, - @RequestParam String fileName, - @RequestParam String contentType - ){ - S3ResDTO.PresignedUrl response = s3Service.generateUploadPresignedUrl(folder, fileName, contentType); - return BasicResponse.success(CommonSuccessStatus._OK, response); - } -} diff --git a/src/main/java/com/meetkey/server/global/s3/S3ResDTO.java b/src/main/java/com/meetkey/server/global/s3/S3ResDTO.java deleted file mode 100644 index 0b6174c..0000000 --- a/src/main/java/com/meetkey/server/global/s3/S3ResDTO.java +++ /dev/null @@ -1,11 +0,0 @@ -package com.meetkey.server.global.s3; - -import lombok.Builder; - -public class S3ResDTO { - @Builder - public record PresignedUrl( - String url, - String key - ){} -} diff --git a/src/main/java/com/meetkey/server/global/s3/S3Service.java b/src/main/java/com/meetkey/server/global/s3/S3Service.java index 267964b..5d23737 100644 --- a/src/main/java/com/meetkey/server/global/s3/S3Service.java +++ b/src/main/java/com/meetkey/server/global/s3/S3Service.java @@ -1,8 +1,16 @@ package com.meetkey.server.global.s3; +import com.meetkey.server.domain.member.dto.ProfileResDTO; +import com.meetkey.server.domain.member.entity.Member; +import com.meetkey.server.domain.member.entity.mapping.MemberPhoto; +import com.meetkey.server.domain.member.exception.MemberErrorStatus; +import com.meetkey.server.domain.member.exception.MemberException; +import com.meetkey.server.domain.member.repository.MemberPhotoRepository; +import com.meetkey.server.domain.member.repository.MemberRepository; import lombok.RequiredArgsConstructor; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; import software.amazon.awssdk.auth.credentials.DefaultCredentialsProvider; import software.amazon.awssdk.regions.Region; import software.amazon.awssdk.services.s3.S3Client; @@ -15,20 +23,29 @@ import software.amazon.awssdk.services.s3.presigner.model.PresignedPutObjectRequest; import java.time.Duration; +import java.util.List; import java.util.UUID; +import java.util.stream.Collectors; @Service @RequiredArgsConstructor public class S3Service { private final S3Presigner s3Presigner; private final S3Client s3Client; + private final MemberRepository memberRepository; + private final MemberPhotoRepository memberPhotoRepository; @Value("${cloud.aws.s3.bucket}") private String bucket; // S3에 이미지 업로드용 Presigned url 발급 - public S3ResDTO.PresignedUrl generateUploadPresignedUrl(String folder, String originalFileName, String contentType){ - String key = folder + "/" + UUID.randomUUID() +"-" + originalFileName; + public ProfileResDTO.MemberPhotoUrl generateMemberPhotoPresignedUrl( + Long memberId, + String originalFileName, + String contentType + ){ + // 유저별 폴더 구조 생성 : profiles/{memberId}/{UUID}-{fileName} + String key = "profiles/" + memberId + "/" + UUID.randomUUID() + "-" + originalFileName; PutObjectRequest putObjectRequest = PutObjectRequest.builder() .bucket(bucket) @@ -41,9 +58,10 @@ public S3ResDTO.PresignedUrl generateUploadPresignedUrl(String folder, String or .signatureDuration(Duration.ofMinutes(5)) ); - return S3ResDTO.PresignedUrl.builder() + return ProfileResDTO.MemberPhotoUrl.builder() .url(presignedPutObjectRequest.url().toString()) - .key(key).build(); + .key(key) + .build(); } // S3 이미지 조회용 Presigned url 발급 @@ -63,9 +81,7 @@ public String generateGetPresignedUrl(String key){ } // s3에서 이미지 삭제 - public void deleteFile(String imageUrl){ - String key = extractKeyFromUrl(imageUrl); - + public void deleteFile(String key){ s3Client.deleteObject(DeleteObjectRequest.builder() .bucket(bucket) .key(key) @@ -73,9 +89,35 @@ public void deleteFile(String imageUrl){ ); } - // s3 url에서 key 추출 - private String extractKeyFromUrl(String imageUrl){ - int idx = imageUrl.indexOf(".amazonaws.com/") + ".amazonaws.com/".length(); - return imageUrl.substring(idx); + @Transactional + public void registerMemberPhotoKeys(Long memberId, List s3Keys){ + Member member = memberRepository.findById(memberId) + .orElseThrow(() -> new MemberException(MemberErrorStatus.MEMBER_NOT_FOUND)); + + List oldPhotos = memberPhotoRepository.findAllByMember(member); + + if (!oldPhotos.isEmpty()) { + oldPhotos.forEach(photo -> deleteFile(photo.getMemberPhotoUrl())); + memberPhotoRepository.deleteAllInBatch(oldPhotos); + } + + List photos = s3Keys.stream() + .map(key -> MemberPhoto.builder() + .member(member) + .memberPhotoUrl(key) + .build()) + .collect(Collectors.toList()); + + memberPhotoRepository.saveAll(photos); + } + + + public List getMemberPhotoUrls(Long memberId) { + Member member = memberRepository.findById(memberId) + .orElseThrow(() -> new MemberException(MemberErrorStatus.MEMBER_NOT_FOUND)); + + return memberPhotoRepository.findAllByMember(member).stream() + .map(photo -> generateGetPresignedUrl(photo.getMemberPhotoUrl())) + .collect(Collectors.toList()); } }