diff --git a/src/main/java/com/server/eventee/domain/event/excepiton/status/EventErrorStatus.java b/src/main/java/com/server/eventee/domain/event/excepiton/status/EventErrorStatus.java index 2b76121..b1c9987 100644 --- a/src/main/java/com/server/eventee/domain/event/excepiton/status/EventErrorStatus.java +++ b/src/main/java/com/server/eventee/domain/event/excepiton/status/EventErrorStatus.java @@ -29,10 +29,11 @@ public enum EventErrorStatus implements BaseCode { GROUP_CREATE_FAILED(HttpStatus.BAD_REQUEST, "EVENT-0200", "이벤트 그룹 생성 중 오류가 발생했습니다."), GROUP_COUNT_MISMATCH(HttpStatus.BAD_REQUEST, "EVENT-0201", "그룹 수가 이벤트 설정과 일치하지 않습니다."), GROUP_NOT_FOUND(HttpStatus.NOT_FOUND, "EVENT-0202", "존재하지 않는 그룹입니다."), - GROUP_NOT_BELONGS_TO_EVENT(HttpStatus.BAD_REQUEST, "EVENT-0203", "해당 그룹은 지정된 이벤트에 속하지 않습니다.") + GROUP_NOT_BELONGS_TO_EVENT(HttpStatus.BAD_REQUEST, "EVENT-0203", "해당 그룹은 지정된 이벤트에 속하지 않습니다."), - ; + SUPPORTED_NOT_TYPE(HttpStatus.BAD_REQUEST,"FILE-5000","해당 유형은 지원되지 않는 파일 유형입니다." ), + FILE_NOT_FOUND(HttpStatus.NOT_FOUND,"FILE_4000","해당 파일은 존재하지 않습니다."); private final HttpStatus httpStatus; private final String code; diff --git a/src/main/java/com/server/eventee/domain/file/controller/FileController.java b/src/main/java/com/server/eventee/domain/file/controller/FileController.java new file mode 100644 index 0000000..651fb55 --- /dev/null +++ b/src/main/java/com/server/eventee/domain/file/controller/FileController.java @@ -0,0 +1,75 @@ +package com.server.eventee.domain.file.controller; + +import com.server.eventee.domain.file.dto.FileRequest; +import com.server.eventee.domain.file.dto.FileUploadResponse; +import com.server.eventee.domain.file.service.FileService; +import com.server.eventee.domain.member.dto.MemberProfileImageDto; +import com.server.eventee.domain.member.dto.MemberProfileImageDto.PresignedUrlResponse; +import com.server.eventee.domain.member.model.Member; +import com.server.eventee.global.exception.BaseResponse; +import com.server.eventee.global.exception.codes.SuccessCode; +import com.server.eventee.global.filter.CurrentMember; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@Tag(name = "File", description = "이미지 관련 API") +@RestController +@Validated +@RequiredArgsConstructor +@RequestMapping("/api/v1/file") +public class FileController { + private final FileService fileService; + + @Operation( + summary = "이미지 Presigned URL 발급 (PUT)", + description = """ + S3에 직접 PUT 업로드할 URL을 발급합니다. + 프론트는 해당 URL로 이미지를 업로드한 뒤 /confirm을 호출해야 합니다. + """ + ) + @PostMapping("/presigned-url") + public BaseResponse createPresignedUrl( + @CurrentMember Member member, + @Valid @RequestBody FileRequest.FileUploadRequest request) { + + FileUploadResponse response = fileService.createPresignedUrl(request); + return BaseResponse.of(SuccessCode.SUCCESS, response); + } + + //업로드 확정 (PUT 완료 후 호출) + @Operation( + summary = "프로필 이미지 업로드 확정", + description = "PUT 업로드가 완료된 이미지를 확인하고 회원 프로필에 반영합니다." + ) + @PostMapping("/confirm") + public BaseResponse confirmProfileImage( + @CurrentMember Member member, + @Valid @RequestBody FileRequest.FileConfirmRequest request) { + + String imageUrl = fileService.confirmUpload(member, request); + return BaseResponse.of(SuccessCode.SUCCESS, imageUrl); + } + + //프로필 이미지 삭제 + @Operation( + summary = "프로필 이미지 삭제", + description = "S3에서 기존 프로필 이미지를 삭제하고 회원 프로필을 초기화합니다." + ) + @DeleteMapping("/profile-image") + public BaseResponse deleteProfileImage( + @CurrentMember Member member, + @Valid @RequestBody FileRequest.FileDeleteRequest request) { + + fileService.deleteFile(request); + return BaseResponse.of(SuccessCode.SUCCESS, "success"); + } + +} diff --git a/src/main/java/com/server/eventee/domain/file/dto/FileRequest.java b/src/main/java/com/server/eventee/domain/file/dto/FileRequest.java new file mode 100644 index 0000000..5c713a1 --- /dev/null +++ b/src/main/java/com/server/eventee/domain/file/dto/FileRequest.java @@ -0,0 +1,71 @@ +package com.server.eventee.domain.file.dto; + + +import jakarta.validation.constraints.NotNull; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +public class FileRequest { + + /* ----------------------------------------------------- + Presigned URL 발급 요청 (PUT 업로드 Intent) + type: PROFILE / GROUP / EVENT / POST / COMMENT + refId: 어떤 엔티티의 이미지인지 + ----------------------------------------------------- */ + @Getter + @NoArgsConstructor + @AllArgsConstructor + @Builder + public static class FileUploadRequest { + + @NotNull + private String type; + + @NotNull + private Long refId; + + @NotNull + private String contentType; + + @NotNull + private Long contentLength; + } + + /* ----------------------------------------------------- + 업로드 확정 요청 (/confirm) + fileUrl: S3 공개 URL + ----------------------------------------------------- */ + @Getter + @NoArgsConstructor + @AllArgsConstructor + @Builder + public static class FileConfirmRequest { + + @NotNull + private String type; + + @NotNull + private Long refId; + + @NotNull + private String fileUrl; + } + + /* ----------------------------------------------------- + 파일 삭제 요청 + ----------------------------------------------------- */ + @Getter + @NoArgsConstructor + @AllArgsConstructor + @Builder + public static class FileDeleteRequest { + + @NotNull + private String type; + + @NotNull + private Long refId; + } +} diff --git a/src/main/java/com/server/eventee/domain/file/dto/FileUploadResponse.java b/src/main/java/com/server/eventee/domain/file/dto/FileUploadResponse.java new file mode 100644 index 0000000..5debbef --- /dev/null +++ b/src/main/java/com/server/eventee/domain/file/dto/FileUploadResponse.java @@ -0,0 +1,17 @@ +package com.server.eventee.domain.file.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class FileUploadResponse { + + private String presignedUrl; + private String publicUrl; +} + diff --git a/src/main/java/com/server/eventee/domain/file/service/FileService.java b/src/main/java/com/server/eventee/domain/file/service/FileService.java new file mode 100644 index 0000000..0840df7 --- /dev/null +++ b/src/main/java/com/server/eventee/domain/file/service/FileService.java @@ -0,0 +1,16 @@ +package com.server.eventee.domain.file.service; + +import com.server.eventee.domain.file.dto.FileRequest.FileConfirmRequest; +import com.server.eventee.domain.file.dto.FileRequest.FileDeleteRequest; +import com.server.eventee.domain.file.dto.FileRequest.FileUploadRequest; +import com.server.eventee.domain.file.dto.FileUploadResponse; +import com.server.eventee.domain.member.model.Member; + +public interface FileService { + + FileUploadResponse createPresignedUrl(FileUploadRequest request); + + String confirmUpload(Member member,FileConfirmRequest request); + + void deleteFile(FileDeleteRequest request); +} diff --git a/src/main/java/com/server/eventee/domain/file/service/FileServiceImpl.java b/src/main/java/com/server/eventee/domain/file/service/FileServiceImpl.java new file mode 100644 index 0000000..606fb67 --- /dev/null +++ b/src/main/java/com/server/eventee/domain/file/service/FileServiceImpl.java @@ -0,0 +1,222 @@ +package com.server.eventee.domain.file.service; + +import com.server.eventee.domain.event.excepiton.EventHandler; +import com.server.eventee.domain.event.excepiton.status.EventErrorStatus; +import com.server.eventee.domain.event.model.Event; +import com.server.eventee.domain.event.repository.EventRepository; +import com.server.eventee.domain.file.dto.FileRequest.FileConfirmRequest; +import com.server.eventee.domain.file.dto.FileRequest.FileDeleteRequest; +import com.server.eventee.domain.file.dto.FileRequest.FileUploadRequest; +import com.server.eventee.domain.file.dto.FileUploadResponse; +import com.server.eventee.domain.group.model.Group; +import com.server.eventee.domain.group.repository.GroupRepository; +import com.server.eventee.domain.member.exception.MemberHandler; +import com.server.eventee.domain.member.exception.status.MemberErrorStatus; +import com.server.eventee.domain.member.model.Member; +import com.server.eventee.domain.member.repository.MemberRepository; +import com.server.eventee.global.aws.S3Props; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.StringUtils; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.model.DeleteObjectRequest; +import software.amazon.awssdk.services.s3.model.HeadObjectRequest; +import software.amazon.awssdk.services.s3.presigner.S3Presigner; +import software.amazon.awssdk.services.s3.presigner.model.PresignedPutObjectRequest; + +import java.time.Duration; +import java.util.UUID; + +@Service +@Slf4j +@RequiredArgsConstructor +public class FileServiceImpl implements FileService { + + private final S3Client s3; + private final S3Presigner presigner; + private final S3Props props; + + private final MemberRepository memberRepository; + private final GroupRepository groupRepository; + private final EventRepository eventRepository; + + @Override + @Transactional(readOnly = true) + public FileUploadResponse createPresignedUrl(FileUploadRequest request) { + + validateContentType(request.getContentType()); + validateLength(request.getContentLength()); + + String ext = mapExt(request.getContentType()); + String key = buildKey(request.getType(), request.getRefId(), ext); + + PresignedPutObjectRequest presigned; + try { + presigned = presigner.presignPutObject(b -> b + .signatureDuration(Duration.ofMinutes(5)) + .putObjectRequest(r -> r + .bucket(props.getBucket()) + .key(key) + .contentType(request.getContentType())) + ); + } catch (Exception e) { + throw new RuntimeException("Presigned URL 생성 실패", e); + } + + return FileUploadResponse.builder() + .presignedUrl(presigned.url().toString()) + .publicUrl(buildPublicUrl(key)) + .build(); + } + + @Override + @Transactional + public String confirmUpload(Member member, FileConfirmRequest request) { + + String key = extractKeyFromUrl(request.getFileUrl()); + + validateObjectExists(key); + + String url = request.getFileUrl(); + + switch (request.getType().toUpperCase()) { + + case "PROFILE" -> { + member.updateProfileImage(key, url); + memberRepository.save(member); + } + + case "GROUP" -> { + Group group = groupRepository.findById(request.getRefId()) + .orElseThrow(() -> new EventHandler(EventErrorStatus.GROUP_NOT_FOUND)); + group.updateGroupImg(url); + } + + case "EVENT" -> { + Event event = eventRepository.findById(request.getRefId()) + .orElseThrow(() -> new EventHandler(EventErrorStatus.EVENT_NOT_FOUND)); + event.updateThumbnail(url); + } + + case "POST" -> { + // TODO: post 이미지 있을 경우 여기에 반영해야됨 + } + + case "COMMENT" -> { + // TODO: comment 이미지 있을 경우 여기에 반영해야딤 + } + + default -> throw new RuntimeException("지원되지 않는 type"); + } + + return url; + } + + @Override + @Transactional + public void deleteFile(FileDeleteRequest request) { + + switch (request.getType().toUpperCase()) { + + case "PROFILE" -> { + Member member = memberRepository.findById(request.getRefId()) + .orElseThrow(() -> new MemberHandler(MemberErrorStatus.MEMBER_NOT_FOUND)); + + if (StringUtils.isNotBlank(member.getProfileImageUrl())) { + deleteObject(extractKeyFromUrl(member.getProfileImageUrl())); + member.clearProfileImage(); + } + } + + case "GROUP" -> { + Group group = groupRepository.findById(request.getRefId()) + .orElseThrow(() -> new EventHandler(EventErrorStatus.GROUP_NOT_FOUND)); + + if (StringUtils.isNotBlank(group.getGroupImg())) { + deleteObject(extractKeyFromUrl(group.getGroupImg())); + group.updateGroupImg(null); + } + } + + case "EVENT" -> { + Event event = eventRepository.findById(request.getRefId()) + .orElseThrow(() -> new EventHandler(EventErrorStatus.EVENT_NOT_FOUND)); + + if (StringUtils.isNotBlank(event.getThumbnailUrl())) { + deleteObject(extractKeyFromUrl(event.getThumbnailUrl())); + event.updateThumbnail(null); + } + } + + default -> throw new EventHandler(EventErrorStatus.SUPPORTED_NOT_TYPE); + } + } + + /* --------------------------------------------------------------------------- + 내부 유틸 + --------------------------------------------------------------------------- */ + + private void validateContentType(String contentType) { + if (props.getAllowedContentTypes().stream() + .noneMatch(allowed -> allowed.equalsIgnoreCase(contentType))) { + throw new MemberHandler(MemberErrorStatus.MEMBER_IMAGE_INVALID_CONTENT_TYPE); + } + } + + private void validateLength(long len) { + if (len <= 0 || len > props.getMaxUploadSizeBytes()) { + throw new MemberHandler(MemberErrorStatus.MEMBER_IMAGE_INVALID_SIZE); + } + } + + private String mapExt(String contentType) { + return switch (contentType) { + case "image/jpeg" -> ".jpg"; + case "image/png" -> ".png"; + case "image/webp" -> ".webp"; + default -> throw new MemberHandler(MemberErrorStatus.MEMBER_IMAGE_INVALID_CONTENT_TYPE); + }; + } + + private String buildKey(String type, Long refId, String ext) { + return switch (type.toUpperCase()) { + case "PROFILE" -> "profiles/" + refId + "/" + UUID.randomUUID() + ext; + case "GROUP" -> "groups/" + refId + "/" + UUID.randomUUID() + ext; + case "EVENT" -> "events/" + refId + "/" + UUID.randomUUID() + ext; + case "POST" -> "posts/" + refId + "/" + UUID.randomUUID() + ext; + case "COMMENT" -> "comments/" + refId + "/" + UUID.randomUUID() + ext; + default -> throw new EventHandler(EventErrorStatus.SUPPORTED_NOT_TYPE); + }; + } + + private void validateObjectExists(String key) { + try { + s3.headObject(HeadObjectRequest.builder() + .bucket(props.getBucket()) + .key(key) + .build()); + } catch (Exception e) { + throw new EventHandler(EventErrorStatus.FILE_NOT_FOUND); + } + } + + private void deleteObject(String key) { + try { + s3.deleteObject(DeleteObjectRequest.builder() + .bucket(props.getBucket()) + .key(key) + .build()); + } catch (Exception ignored) { + } + } + + private String extractKeyFromUrl(String url) { + return url.substring(url.indexOf(".amazonaws.com/") + ".amazonaws.com/".length()); + } + + private String buildPublicUrl(String key) { + return "https://" + props.getBucket() + ".s3." + props.getRegion() + ".amazonaws.com/" + key; + } +} diff --git a/src/main/java/com/server/eventee/domain/group/model/Group.java b/src/main/java/com/server/eventee/domain/group/model/Group.java index 67033d2..62bac74 100644 --- a/src/main/java/com/server/eventee/domain/group/model/Group.java +++ b/src/main/java/com/server/eventee/domain/group/model/Group.java @@ -122,4 +122,8 @@ public void leaveMember(Member member){ } + public void updateGroupImg(String imgUrl) { + this.groupImg = imgUrl; + } + } diff --git a/src/main/java/com/server/eventee/domain/member/controller/MemberController.java b/src/main/java/com/server/eventee/domain/member/controller/MemberController.java index 64812cf..3a17e85 100644 --- a/src/main/java/com/server/eventee/domain/member/controller/MemberController.java +++ b/src/main/java/com/server/eventee/domain/member/controller/MemberController.java @@ -42,52 +42,6 @@ public BaseResponse checkAndUpdateNickname( return BaseResponse.of(SuccessCode.SUCCESS, updatedNickname); } - //Presigned URL (PUT) 발급 - @Operation( - summary = "프로필 이미지 Presigned URL 발급 (PUT)", - description = """ - S3에 직접 PUT 업로드할 URL을 발급합니다. - 프론트는 해당 URL로 이미지를 업로드한 뒤 /confirm을 호출해야 합니다. - """ - ) - @PostMapping("/profile-image/presigned-url") - public BaseResponse createPresignedUrl( - @CurrentMember Member member, - @Valid @RequestBody MemberProfileImageDto.UploadIntentRequest request) { - - MemberProfileImageDto.PresignedUrlResponse response = - memberService.createPresignedUrl(member, request); - return BaseResponse.of(SuccessCode.SUCCESS, response); - } - - //업로드 확정 (PUT 완료 후 호출) - @Operation( - summary = "프로필 이미지 업로드 확정", - description = "PUT 업로드가 완료된 이미지를 확인하고 회원 프로필에 반영합니다." - ) - @PostMapping("/profile-image/confirm") - public BaseResponse confirmProfileImage( - @CurrentMember Member member, - @Valid @RequestBody MemberProfileImageDto.ConfirmUploadRequest request) { - - String imageUrl = memberService.confirmUpload(member, request); - return BaseResponse.of(SuccessCode.SUCCESS, imageUrl); - } - - //프로필 이미지 삭제 - @Operation( - summary = "프로필 이미지 삭제", - description = "S3에서 기존 프로필 이미지를 삭제하고 회원 프로필을 초기화합니다." - ) - @DeleteMapping("/profile-image") - public BaseResponse deleteProfileImage( - @CurrentMember Member member) { - - MemberProfileImageDto.DeleteImageResponse response = - memberService.deleteProfileImage(member); - return BaseResponse.of(SuccessCode.SUCCESS, response); - } - //마이페이지 @Operation( summary = "마이페이지 정보 조회", diff --git a/src/main/java/com/server/eventee/domain/member/converter/MemberConverter.java b/src/main/java/com/server/eventee/domain/member/converter/MemberConverter.java index 1e8dbf7..5a394fc 100644 --- a/src/main/java/com/server/eventee/domain/member/converter/MemberConverter.java +++ b/src/main/java/com/server/eventee/domain/member/converter/MemberConverter.java @@ -14,7 +14,7 @@ public class MemberConverter { public MemberMyPageResponse toResponse(Member member, List joinedEvents) { List joinedEventDtos = joinedEvents.stream() - .map(event -> toJoinedEvent(event, member)) // ✅ member 정보도 함께 넘김 + .map(event -> toJoinedEvent(event, member)) .toList(); // 안전 처리된 프로필 이미지 URL diff --git a/src/main/java/com/server/eventee/domain/member/service/MemberService.java b/src/main/java/com/server/eventee/domain/member/service/MemberService.java index c64f532..28a9543 100644 --- a/src/main/java/com/server/eventee/domain/member/service/MemberService.java +++ b/src/main/java/com/server/eventee/domain/member/service/MemberService.java @@ -13,12 +13,6 @@ public interface MemberService { String checkAndUpdateNickname(Member member, String nickname); - - String confirmUpload(Member member, ConfirmUploadRequest request); - - DeleteImageResponse deleteProfileImage(Member member); - - PresignedUrlResponse createPresignedUrl(Member member, UploadIntentRequest request); MemberMyPageResponse getMyPageInfo(Member member); } diff --git a/src/main/java/com/server/eventee/domain/member/service/MemberServiceImpl.java b/src/main/java/com/server/eventee/domain/member/service/MemberServiceImpl.java index a062a00..ef4d9a6 100644 --- a/src/main/java/com/server/eventee/domain/member/service/MemberServiceImpl.java +++ b/src/main/java/com/server/eventee/domain/member/service/MemberServiceImpl.java @@ -38,14 +38,6 @@ public class MemberServiceImpl implements MemberService { private final MemberEventRepository memberEventRepository; private final MemberConverter memberConverter; - private final S3Client s3; - private final S3Presigner presigner; - private final S3Props props; - - private static final Pattern KEY_PATTERN = - Pattern.compile("^profiles/[0-9]+/[A-Za-z0-9\\-]+\\.(jpg|jpeg|png|webp)$"); - - // 닉네임 중복 확인 및 변경 @Override @Transactional @@ -71,75 +63,6 @@ public String checkAndUpdateNickname(Member member, String nickname) { return trimmed; } - // Presigned URL (PUT) 발급 - @Override - @Transactional(readOnly = true) - public PresignedUrlResponse createPresignedUrl(Member member, UploadIntentRequest request) { - - log.info("===== [Presigned URL 요청] ====="); - log.info("[요청 Member] memberId={}", member.getId()); - - if (request == null) { - log.error("[ERROR] request 객체가 null 입니다"); - throw new MemberHandler(MemberErrorStatus.MEMBER_IMAGE_INVALID_REQUEST); - } - - log.info("[요청 바디] contentType='{}', contentLength={}", - request.getContentType(), - request.getContentLength() - ); - - // 유효성 검사 - try { - validateContentType(request.getContentType()); - log.info("[검증] contentType 유효"); - } catch (Exception e) { - log.error("[검증 실패] contentType='{}'", request.getContentType()); - throw e; - } - - try { - validateLength(request.getContentLength()); - log.info("[검증] contentLength 유효"); - } catch (Exception e) { - log.error("[검증 실패] contentLength={}", request.getContentLength()); - throw e; - } - - // 확장자 매핑 - String ext = mapExt(request.getContentType()); - log.info("[확장자 매핑] {} → {}", request.getContentType(), ext); - - // S3 key 생성 - String key = props.getKeyPrefix() + member.getId() + "/" + UUID.randomUUID() + ext; - log.info("[S3 Key 생성] key={}", key); - - // Presigned URL 생성 - PresignedPutObjectRequest presigned; - try { - presigned = presigner.presignPutObject(b -> b - .signatureDuration(Duration.ofMinutes(5)) - .putObjectRequest(r -> r - .bucket(props.getBucket()) - .key(key) - .contentType(request.getContentType())) - ); - } catch (Exception e) { - log.error("[ERROR] Presigned URL 생성 실패: {}", e.getMessage(), e); - throw new MemberHandler(MemberErrorStatus.MEMBER_IMAGE_PRESIGNED_ERROR); - } - - log.info("[Presigned URL 생성 완료]"); - log.info(" - URL: {}", presigned.url()); - log.info(" - Expires: {} sec", 300); - log.info("=============================="); - - return PresignedUrlResponse.builder() - .url(presigned.url().toString()) - .key(key) - .expiresIn(300) - .build(); - } // 마이페이지 정보 조회 @@ -153,156 +76,4 @@ public MemberMyPageResponse getMyPageInfo(Member member) { return memberConverter.toResponse(member, joinedEvents); } - @Override - @Transactional - public String confirmUpload(Member member, ConfirmUploadRequest request) { - - log.info("===== [업로드 확정 요청 시작] ====="); - log.info("[요청자] memberId={}", member.getId()); - - log.info("[Confirm 요청 바디] key={}, contentType={}, size={}", - request.getKey(), - request.getContentType(), - request.getSize() - ); - - try { - ensureKeyOwnedByMember(member, request.getKey()); - log.info("[Key 소유자 검증 통과]"); - } catch (Exception e) { - log.error("[Key 검증 실패] key={}", request.getKey()); - throw e; - } - - try { - validateContentType(request.getContentType()); - log.info("[ContentType 검증 통과]"); - } catch (Exception e) { - log.error("[ContentType 검증 실패] {}", request.getContentType()); - throw e; - } - - try { - validateLength(request.getSize()); - log.info("[ContentLength 검증 통과]"); - } catch (Exception e) { - log.error("[ContentLength 검증 실패] {}", request.getSize()); - throw e; - } - - HeadObjectResponse head; - try { - head = s3.headObject(HeadObjectRequest.builder() - .bucket(props.getBucket()) - .key(request.getKey()) - .build()); - log.info("[S3 HeadObject 조회 성공] contentType={}", head.contentType()); - } catch (Exception e) { - log.error("[S3 HeadObject 조회 실패] key={}", request.getKey()); - throw new MemberHandler(MemberErrorStatus.MEMBER_IMAGE_NOT_FOUND); - } - - // ContentType 체크 - if (!StringUtils.equals(head.contentType(), request.getContentType())) { - log.error("[S3 ContentType 불일치] head={}, request={}", - head.contentType(), - request.getContentType()); - throw new MemberHandler(MemberErrorStatus.MEMBER_IMAGE_CONTENT_TYPE_MISMATCH); - } - - // 기존 이미지 있으면 삭제 - if (StringUtils.isNotBlank(member.getProfileImageUrl())) { - log.info("[기존 이미지 삭제] url={}", member.getProfileImageUrl()); - deleteIfExists(objectKeyFromUrl(member.getProfileImageUrl())); - } - - String imageUrl = buildPublicUrl(request.getKey()); - member.updateProfileImage(request.getKey(), imageUrl); - memberRepository.save(member); - - log.info("[프로필 이미지 반영 완료] memberId={}, newUrl={}", member.getId(), imageUrl); - log.info("===== [업로드 확정 요청 종료] ====="); - - return imageUrl; - } - - // 기존 프로필 이미지 삭제 - @Override - @Transactional - public DeleteImageResponse deleteProfileImage(Member member) { - String prevUrl = member.getProfileImageUrl(); - if (StringUtils.isBlank(prevUrl)) { - return DeleteImageResponse.builder().status("not_found").build(); - } - - deleteIfExists(objectKeyFromUrl(prevUrl)); - member.clearProfileImage(); - memberRepository.save(member); - - log.info("[프로필 이미지 삭제 완료] memberId={}, prevUrl={}", member.getId(), prevUrl); - - return DeleteImageResponse.builder() - .previousUrl(prevUrl) - .status("deleted") - .build(); - } - - private void validateContentType(String contentType) { - if (props.getAllowedContentTypes().stream() - .noneMatch(allowed -> allowed.equalsIgnoreCase(contentType))) { - throw new MemberHandler(MemberErrorStatus.MEMBER_IMAGE_INVALID_CONTENT_TYPE); - } - } - - private void validateLength(long len) { - if (len <= 0 || len > props.getMaxUploadSizeBytes()) { - throw new MemberHandler(MemberErrorStatus.MEMBER_IMAGE_INVALID_SIZE); - } - } - - private void ensureKeyOwnedByMember(Member member, String key) { - String expectedPrefix = props.getKeyPrefix() + member.getId() + "/"; - - if (!key.startsWith(expectedPrefix)) { - log.error("[Key Prefix 불일치] expectedPrefix={}, key={}", expectedPrefix, key); - throw new MemberHandler(MemberErrorStatus.MEMBER_IMAGE_INVALID_KEY); - } - - if (!KEY_PATTERN.matcher(key).matches()) { - log.error("[Key Pattern 불일치] key={}", key); - throw new MemberHandler(MemberErrorStatus.MEMBER_IMAGE_INVALID_KEY); - } - } - - - private String mapExt(String contentType) { - return switch (contentType) { - case "image/jpeg" -> ".jpg"; - case "image/png" -> ".png"; - case "image/webp" -> ".webp"; - default -> throw new MemberHandler(MemberErrorStatus.MEMBER_IMAGE_INVALID_CONTENT_TYPE); - }; - } - - private void deleteIfExists(String key) { - try { - s3.deleteObject(DeleteObjectRequest.builder() - .bucket(props.getBucket()) - .key(key) - .build()); - } catch (Exception ignored) { - } - } - - private String objectKeyFromUrl(String url) { - String s3Domain = props.getBucket() + ".s3." + props.getRegion() + ".amazonaws.com/"; - return url.contains(s3Domain) - ? StringUtils.substringAfter(url, s3Domain) - : url; - } - - private String buildPublicUrl(String key) { - return "https://" + props.getBucket() + ".s3." + props.getRegion() + ".amazonaws.com/" + key; - } - } diff --git a/src/main/resources/application-prod.properties b/src/main/resources/application-prod.properties index 8d9f232..2e7be60 100644 --- a/src/main/resources/application-prod.properties +++ b/src/main/resources/application-prod.properties @@ -28,7 +28,6 @@ spring.security.oauth2.client.provider.google.user-info-uri=https://www.googleap aws.s3.bucket=${S3_BUCKET} aws.s3.region=${AWS_REGION} -aws.s3.key-prefix=profiles/ aws.s3.allowed-content-types[0]=image/jpeg aws.s3.allowed-content-types[1]=image/png aws.s3.allowed-content-types[2]=image/webp