From e331e850a68ae2696bdc99e7ac53d56ad54faf26 Mon Sep 17 00:00:00 2001 From: JinHyeon Date: Fri, 23 May 2025 22:17:46 +0900 Subject: [PATCH 01/14] =?UTF-8?q?TB-31/feat:=20=ED=9A=8C=EC=9B=90=20?= =?UTF-8?q?=ED=83=88=ED=87=B4=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 --- .../in/delete/DeleteUserController.java | 21 +++++++++++++++++ .../persistence/UserPersistenceAdapter.java | 6 +++++ .../port/in/delete/DeleteUserUseCase.java | 7 ++++++ .../user/application/port/out/UserPort.java | 1 + .../service/delete/DeleteUserService.java | 23 +++++++++++++++++++ 5 files changed, 58 insertions(+) create mode 100644 src/main/java/com/ClubAccount_BE/user/adapter/in/delete/DeleteUserController.java create mode 100644 src/main/java/com/ClubAccount_BE/user/application/port/in/delete/DeleteUserUseCase.java create mode 100644 src/main/java/com/ClubAccount_BE/user/application/service/delete/DeleteUserService.java diff --git a/src/main/java/com/ClubAccount_BE/user/adapter/in/delete/DeleteUserController.java b/src/main/java/com/ClubAccount_BE/user/adapter/in/delete/DeleteUserController.java new file mode 100644 index 0000000..5449bec --- /dev/null +++ b/src/main/java/com/ClubAccount_BE/user/adapter/in/delete/DeleteUserController.java @@ -0,0 +1,21 @@ +package com.ClubAccount_BE.user.adapter.in.delete; + +import com.ClubAccount_BE.core.meta.LoginUser; +import com.ClubAccount_BE.user.application.port.in.delete.DeleteUserUseCase; +import com.ClubAccount_BE.user.domain.User; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/v1/users") +@RequiredArgsConstructor +public class DeleteUserController { + private final DeleteUserUseCase deleteUserUseCase; + + @DeleteMapping("/delete") + public void deleteMyAccount(@LoginUser User user) { + deleteUserUseCase.deleteUser(user); + } +} diff --git a/src/main/java/com/ClubAccount_BE/user/adapter/out/persistence/UserPersistenceAdapter.java b/src/main/java/com/ClubAccount_BE/user/adapter/out/persistence/UserPersistenceAdapter.java index f04f898..83ea876 100644 --- a/src/main/java/com/ClubAccount_BE/user/adapter/out/persistence/UserPersistenceAdapter.java +++ b/src/main/java/com/ClubAccount_BE/user/adapter/out/persistence/UserPersistenceAdapter.java @@ -31,6 +31,12 @@ public void save(User user) { UserMapper.toDomain(saved); } + @Override + public void delete(User user) { + UserEntity entity = UserMapper.toEntity(user); + userRepository.delete(entity); + } + @Override public User getUserByAuthId(String authId) { return userRepository.getByAuthId(authId) diff --git a/src/main/java/com/ClubAccount_BE/user/application/port/in/delete/DeleteUserUseCase.java b/src/main/java/com/ClubAccount_BE/user/application/port/in/delete/DeleteUserUseCase.java new file mode 100644 index 0000000..3583418 --- /dev/null +++ b/src/main/java/com/ClubAccount_BE/user/application/port/in/delete/DeleteUserUseCase.java @@ -0,0 +1,7 @@ +package com.ClubAccount_BE.user.application.port.in.delete; + +import com.ClubAccount_BE.user.domain.User; + +public interface DeleteUserUseCase { + void deleteUser(User user); +} diff --git a/src/main/java/com/ClubAccount_BE/user/application/port/out/UserPort.java b/src/main/java/com/ClubAccount_BE/user/application/port/out/UserPort.java index 5d2b0c8..3aa8d52 100644 --- a/src/main/java/com/ClubAccount_BE/user/application/port/out/UserPort.java +++ b/src/main/java/com/ClubAccount_BE/user/application/port/out/UserPort.java @@ -4,4 +4,5 @@ public interface UserPort { void save(User user); + void delete(User user); } diff --git a/src/main/java/com/ClubAccount_BE/user/application/service/delete/DeleteUserService.java b/src/main/java/com/ClubAccount_BE/user/application/service/delete/DeleteUserService.java new file mode 100644 index 0000000..d87f451 --- /dev/null +++ b/src/main/java/com/ClubAccount_BE/user/application/service/delete/DeleteUserService.java @@ -0,0 +1,23 @@ +package com.ClubAccount_BE.user.application.service.delete; + +import com.ClubAccount_BE.user.application.port.in.delete.DeleteUserUseCase; +import com.ClubAccount_BE.user.application.port.out.FindUserPort; +import com.ClubAccount_BE.user.application.port.out.UserPort; +import com.ClubAccount_BE.user.domain.User; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + + +@Service +@RequiredArgsConstructor +@Transactional +public class DeleteUserService implements DeleteUserUseCase { + + private final UserPort userPort; + + @Override + public void deleteUser(User user) { + userPort.delete(user); + } +} From 7a2cbd931fd716bf0b3733a2da102d01c26a1e26 Mon Sep 17 00:00:00 2001 From: JinHyeon Date: Fri, 23 May 2025 22:18:40 +0900 Subject: [PATCH 02/14] =?UTF-8?q?TB-31/docs:=20=ED=9A=8C=EC=9B=90=20?= =?UTF-8?q?=ED=83=88=ED=87=B4=20=EC=8A=A4=EC=9B=A8=EA=B1=B0=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../user/adapter/in/delete/DeleteUserApi.java | 11 +++++++++++ .../user/adapter/in/delete/DeleteUserController.java | 6 ++++-- 2 files changed, 15 insertions(+), 2 deletions(-) create mode 100644 src/main/java/com/ClubAccount_BE/user/adapter/in/delete/DeleteUserApi.java diff --git a/src/main/java/com/ClubAccount_BE/user/adapter/in/delete/DeleteUserApi.java b/src/main/java/com/ClubAccount_BE/user/adapter/in/delete/DeleteUserApi.java new file mode 100644 index 0000000..0893593 --- /dev/null +++ b/src/main/java/com/ClubAccount_BE/user/adapter/in/delete/DeleteUserApi.java @@ -0,0 +1,11 @@ +package com.ClubAccount_BE.user.adapter.in.delete; + +import com.ClubAccount_BE.user.domain.User; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; + +@Tag(name = "User Deletion", description = "회원 탈퇴 API") +public interface DeleteUserApi { + @Operation(summary = "회원 탈퇴", description = "로그인된 사용자가 자신의 계정을 탈퇴합니다.") + void deleteMyAccount(User user); +} diff --git a/src/main/java/com/ClubAccount_BE/user/adapter/in/delete/DeleteUserController.java b/src/main/java/com/ClubAccount_BE/user/adapter/in/delete/DeleteUserController.java index 5449bec..c33d01c 100644 --- a/src/main/java/com/ClubAccount_BE/user/adapter/in/delete/DeleteUserController.java +++ b/src/main/java/com/ClubAccount_BE/user/adapter/in/delete/DeleteUserController.java @@ -11,11 +11,13 @@ @RestController @RequestMapping("/api/v1/users") @RequiredArgsConstructor -public class DeleteUserController { +public class DeleteUserController implements DeleteUserApi { + private final DeleteUserUseCase deleteUserUseCase; @DeleteMapping("/delete") + @Override public void deleteMyAccount(@LoginUser User user) { deleteUserUseCase.deleteUser(user); } -} +} \ No newline at end of file From 1c6723a0d052a9c1fdb181860530eece4eee0cf4 Mon Sep 17 00:00:00 2001 From: JinHyeon Date: Fri, 23 May 2025 22:19:12 +0900 Subject: [PATCH 03/14] =?UTF-8?q?TB-31/chore:=20=EC=82=AC=EC=9A=A9?= =?UTF-8?q?=ED=95=98=EC=A7=80=20=EC=95=8A=EB=8A=94=20import=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 --- .../user/application/service/delete/DeleteUserService.java | 1 - 1 file changed, 1 deletion(-) diff --git a/src/main/java/com/ClubAccount_BE/user/application/service/delete/DeleteUserService.java b/src/main/java/com/ClubAccount_BE/user/application/service/delete/DeleteUserService.java index d87f451..218d079 100644 --- a/src/main/java/com/ClubAccount_BE/user/application/service/delete/DeleteUserService.java +++ b/src/main/java/com/ClubAccount_BE/user/application/service/delete/DeleteUserService.java @@ -1,7 +1,6 @@ package com.ClubAccount_BE.user.application.service.delete; import com.ClubAccount_BE.user.application.port.in.delete.DeleteUserUseCase; -import com.ClubAccount_BE.user.application.port.out.FindUserPort; import com.ClubAccount_BE.user.application.port.out.UserPort; import com.ClubAccount_BE.user.domain.User; import lombok.RequiredArgsConstructor; From 352b24088689c3843c7e79d4b757f8340c09247a Mon Sep 17 00:00:00 2001 From: JinHyeon Date: Sat, 24 May 2025 00:05:11 +0900 Subject: [PATCH 04/14] =?UTF-8?q?TB-31/feat:=20=ED=9A=8C=EC=9B=90=20?= =?UTF-8?q?=ED=83=88=ED=87=B4=20=EC=8B=9C=20=EC=97=B0=EA=B4=80=20Receipt?= =?UTF-8?q?=20=EB=B0=8F=20ReceiptItem=20=EC=82=AD=EC=A0=9C=20=EC=B2=98?= =?UTF-8?q?=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../user/adapter/out/persistence/entity/UserEntity.java | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/main/java/com/ClubAccount_BE/user/adapter/out/persistence/entity/UserEntity.java b/src/main/java/com/ClubAccount_BE/user/adapter/out/persistence/entity/UserEntity.java index 1f39454..ab33240 100644 --- a/src/main/java/com/ClubAccount_BE/user/adapter/out/persistence/entity/UserEntity.java +++ b/src/main/java/com/ClubAccount_BE/user/adapter/out/persistence/entity/UserEntity.java @@ -1,6 +1,7 @@ package com.ClubAccount_BE.user.adapter.out.persistence.entity; import com.ClubAccount_BE.core.entity.TimeBaseEntity; +import com.ClubAccount_BE.receipt.adapter.out.persistence.entity.ReceiptEntity; import jakarta.persistence.*; import lombok.AccessLevel; import lombok.Getter; @@ -10,6 +11,8 @@ import org.hibernate.type.SqlTypes; import org.springframework.data.jpa.domain.support.AuditingEntityListener; +import java.util.ArrayList; +import java.util.List; import java.util.UUID; @@ -38,4 +41,7 @@ public class UserEntity extends TimeBaseEntity { @Column(length = 36, nullable = false, unique = true) @JdbcTypeCode(SqlTypes.CHAR) private UUID link; + + @OneToMany(mappedBy = "user", cascade = CascadeType.ALL, orphanRemoval = true) + private List receipts = new ArrayList<>(); } From 1764c06fd3470eed09551e701c34f1b36f7b5e70 Mon Sep 17 00:00:00 2001 From: JinHyeon Date: Sat, 24 May 2025 00:06:11 +0900 Subject: [PATCH 05/14] =?UTF-8?q?TB-31/refactor:=20/delete=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 --- .../user/adapter/in/delete/DeleteUserController.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/com/ClubAccount_BE/user/adapter/in/delete/DeleteUserController.java b/src/main/java/com/ClubAccount_BE/user/adapter/in/delete/DeleteUserController.java index c33d01c..a0f6fe0 100644 --- a/src/main/java/com/ClubAccount_BE/user/adapter/in/delete/DeleteUserController.java +++ b/src/main/java/com/ClubAccount_BE/user/adapter/in/delete/DeleteUserController.java @@ -15,7 +15,7 @@ public class DeleteUserController implements DeleteUserApi { private final DeleteUserUseCase deleteUserUseCase; - @DeleteMapping("/delete") + @DeleteMapping @Override public void deleteMyAccount(@LoginUser User user) { deleteUserUseCase.deleteUser(user); From dbe7d6da16fea429fd1f4bab17e9a9a4c7352d70 Mon Sep 17 00:00:00 2001 From: JinHyeon Date: Sat, 24 May 2025 02:00:19 +0900 Subject: [PATCH 06/14] =?UTF-8?q?TB-31/refactor:=20@Transactional=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../user/application/service/update/ProfileService.java | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/main/java/com/ClubAccount_BE/user/application/service/update/ProfileService.java b/src/main/java/com/ClubAccount_BE/user/application/service/update/ProfileService.java index 46d6b0d..e51e988 100644 --- a/src/main/java/com/ClubAccount_BE/user/application/service/update/ProfileService.java +++ b/src/main/java/com/ClubAccount_BE/user/application/service/update/ProfileService.java @@ -10,11 +10,13 @@ import lombok.RequiredArgsConstructor; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; import org.springframework.web.multipart.MultipartFile; import static com.ClubAccount_BE.core.exception.ErrorCode.AUTH_PASSWORD_CONFIRM_MISMATCH; @Service +@Transactional @RequiredArgsConstructor public class ProfileService implements ProfileUseCase { From 83f116bcd75a057c8f7f45856587bec3b8cb3986 Mon Sep 17 00:00:00 2001 From: JinHyeon Date: Sat, 24 May 2025 17:04:41 +0900 Subject: [PATCH 07/14] =?UTF-8?q?TB-31/feat:=20=ED=9A=8C=EC=9B=90=ED=83=88?= =?UTF-8?q?=ED=87=B4=EC=8B=9C=20=EC=82=AC=EC=9A=A9=EC=9E=90=20S3=20?= =?UTF-8?q?=EC=9D=B4=EB=AF=B8=EC=A7=80=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ProfileImageRepositoryAdapter.java | 6 +- .../UserImageKeyExtractorAdapter.java | 24 ++++++++ .../port/out/UserImageKeyExtractorPort.java | 8 +++ .../service/delete/DeleteUserService.java | 61 ++++++++++++++++++- 4 files changed, 96 insertions(+), 3 deletions(-) rename src/main/java/com/ClubAccount_BE/user/adapter/out/{ => persistence}/ProfileImageRepositoryAdapter.java (87%) create mode 100644 src/main/java/com/ClubAccount_BE/user/adapter/out/persistence/UserImageKeyExtractorAdapter.java create mode 100644 src/main/java/com/ClubAccount_BE/user/application/port/out/UserImageKeyExtractorPort.java diff --git a/src/main/java/com/ClubAccount_BE/user/adapter/out/ProfileImageRepositoryAdapter.java b/src/main/java/com/ClubAccount_BE/user/adapter/out/persistence/ProfileImageRepositoryAdapter.java similarity index 87% rename from src/main/java/com/ClubAccount_BE/user/adapter/out/ProfileImageRepositoryAdapter.java rename to src/main/java/com/ClubAccount_BE/user/adapter/out/persistence/ProfileImageRepositoryAdapter.java index 956ce97..ba5f510 100644 --- a/src/main/java/com/ClubAccount_BE/user/adapter/out/ProfileImageRepositoryAdapter.java +++ b/src/main/java/com/ClubAccount_BE/user/adapter/out/persistence/ProfileImageRepositoryAdapter.java @@ -1,5 +1,7 @@ -package com.ClubAccount_BE.user.adapter.out; +package com.ClubAccount_BE.user.adapter.out.persistence; +import com.ClubAccount_BE.core.exception.ApiException; +import com.ClubAccount_BE.core.exception.ErrorCode; import com.ClubAccount_BE.user.application.port.out.UploadProfileImagePort; import lombok.RequiredArgsConstructor; import org.springframework.beans.factory.annotation.Value; @@ -40,7 +42,7 @@ public String uploadProfileImage(Long userId, MultipartFile image) { ); } catch (IOException e) { - throw new RuntimeException("S3 업로드 실패", e); + throw new ApiException(ErrorCode.S3_UPLOAD_FAIL, e.getMessage()); } return amazonS3.utilities() diff --git a/src/main/java/com/ClubAccount_BE/user/adapter/out/persistence/UserImageKeyExtractorAdapter.java b/src/main/java/com/ClubAccount_BE/user/adapter/out/persistence/UserImageKeyExtractorAdapter.java new file mode 100644 index 0000000..89466b8 --- /dev/null +++ b/src/main/java/com/ClubAccount_BE/user/adapter/out/persistence/UserImageKeyExtractorAdapter.java @@ -0,0 +1,24 @@ +package com.ClubAccount_BE.user.adapter.out.persistence; + +import com.ClubAccount_BE.user.application.port.out.UserImageKeyExtractorPort; +import org.springframework.stereotype.Component; +import java.net.URLDecoder; +import java.nio.charset.StandardCharsets; + +@Component +public class UserImageKeyExtractorAdapter implements UserImageKeyExtractorPort { + @Override + public String extract(String imageUrl) { + if (imageUrl == null || imageUrl.isBlank()) { + return null; + } + + int index = imageUrl.indexOf(".com/"); + if (index != -1 && index + 5 < imageUrl.length()) { + String key = imageUrl.substring(index + 5); + return URLDecoder.decode(key, StandardCharsets.UTF_8); + } + + return imageUrl; + } +} diff --git a/src/main/java/com/ClubAccount_BE/user/application/port/out/UserImageKeyExtractorPort.java b/src/main/java/com/ClubAccount_BE/user/application/port/out/UserImageKeyExtractorPort.java new file mode 100644 index 0000000..5a46842 --- /dev/null +++ b/src/main/java/com/ClubAccount_BE/user/application/port/out/UserImageKeyExtractorPort.java @@ -0,0 +1,8 @@ +package com.ClubAccount_BE.user.application.port.out; + +/** + * S3 이미지 URL에서 객체 키(key)를 추출하는 포트 + */ +public interface UserImageKeyExtractorPort { + String extract(String imageUrl); +} diff --git a/src/main/java/com/ClubAccount_BE/user/application/service/delete/DeleteUserService.java b/src/main/java/com/ClubAccount_BE/user/application/service/delete/DeleteUserService.java index 218d079..c74b9b7 100644 --- a/src/main/java/com/ClubAccount_BE/user/application/service/delete/DeleteUserService.java +++ b/src/main/java/com/ClubAccount_BE/user/application/service/delete/DeleteUserService.java @@ -1,12 +1,26 @@ package com.ClubAccount_BE.user.application.service.delete; +import com.ClubAccount_BE.core.exception.ApiException; +import com.ClubAccount_BE.core.exception.ErrorCode; +import com.ClubAccount_BE.receipt.application.port.out.FindReceiptPort; +import com.ClubAccount_BE.receipt.domain.Receipt; import com.ClubAccount_BE.user.application.port.in.delete.DeleteUserUseCase; import com.ClubAccount_BE.user.application.port.out.UserPort; + +import com.ClubAccount_BE.user.application.port.out.UserImageKeyExtractorPort; import com.ClubAccount_BE.user.domain.User; 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.services.s3.S3Client; +import software.amazon.awssdk.services.s3.model.Delete; +import software.amazon.awssdk.services.s3.model.DeleteObjectsRequest; +import software.amazon.awssdk.services.s3.model.ObjectIdentifier; +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; @Service @RequiredArgsConstructor @@ -14,9 +28,54 @@ public class DeleteUserService implements DeleteUserUseCase { private final UserPort userPort; + private final FindReceiptPort findReceiptPort; + private final UserImageKeyExtractorPort userImageKeyExtractorPort; + private final S3Client s3Client; + + @Value("${cloud.aws.s3.bucket}") + private String bucketName; @Override public void deleteUser(User user) { + List allImageKeys = new ArrayList<>(); + + // 프로필 이미지 + if (user.getProfileUrl() != null) { + String key = userImageKeyExtractorPort.extract(user.getProfileUrl()); + if (key != null) { + allImageKeys.add(key); + } + } + + // 영수증 이미지 + List receipts = findReceiptPort.getReceiptCategoryList(user); + List receiptImageKeys = receipts.stream() + .map(Receipt::getReceiptImageUrl) + .filter(Objects::nonNull) + .map(userImageKeyExtractorPort::extract) + .filter(Objects::nonNull) + .toList(); + allImageKeys.addAll(receiptImageKeys); + + // S3 이미지 삭제 + if (!allImageKeys.isEmpty()) { + List s3Objects = allImageKeys.stream() + .map(key -> ObjectIdentifier.builder().key(key).build()) + .toList(); + + DeleteObjectsRequest request = DeleteObjectsRequest.builder() + .bucket(bucketName) + .delete(Delete.builder().objects(s3Objects).build()) + .build(); + + try { + s3Client.deleteObjects(request); + } catch (Exception e) { + throw new ApiException(ErrorCode.S3_UPLOAD_FAIL, e.getMessage()); + } + } + + // 회원 삭제 userPort.delete(user); } -} +} \ No newline at end of file From 1a7fabdd4e12c75db00b31d88d961183d4687271 Mon Sep 17 00:00:00 2001 From: JinHyeon Date: Wed, 28 May 2025 05:39:01 +0900 Subject: [PATCH 08/14] =?UTF-8?q?TB-31/refactor:=20s3=20=EC=9D=B4=EB=AF=B8?= =?UTF-8?q?=EC=A7=80=ED=82=A4=20=EC=B6=94=EC=B6=9C=20=ED=8F=AC=ED=8A=B8?= =?UTF-8?q?=EC=99=80=20=EC=96=B4=EB=8B=B5=ED=84=B0=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../UserImageKeyExtractorAdapter.java | 24 ------------------- .../port/out/UserImageKeyExtractorPort.java | 8 ------- 2 files changed, 32 deletions(-) delete mode 100644 src/main/java/com/ClubAccount_BE/user/adapter/out/persistence/UserImageKeyExtractorAdapter.java delete mode 100644 src/main/java/com/ClubAccount_BE/user/application/port/out/UserImageKeyExtractorPort.java diff --git a/src/main/java/com/ClubAccount_BE/user/adapter/out/persistence/UserImageKeyExtractorAdapter.java b/src/main/java/com/ClubAccount_BE/user/adapter/out/persistence/UserImageKeyExtractorAdapter.java deleted file mode 100644 index 89466b8..0000000 --- a/src/main/java/com/ClubAccount_BE/user/adapter/out/persistence/UserImageKeyExtractorAdapter.java +++ /dev/null @@ -1,24 +0,0 @@ -package com.ClubAccount_BE.user.adapter.out.persistence; - -import com.ClubAccount_BE.user.application.port.out.UserImageKeyExtractorPort; -import org.springframework.stereotype.Component; -import java.net.URLDecoder; -import java.nio.charset.StandardCharsets; - -@Component -public class UserImageKeyExtractorAdapter implements UserImageKeyExtractorPort { - @Override - public String extract(String imageUrl) { - if (imageUrl == null || imageUrl.isBlank()) { - return null; - } - - int index = imageUrl.indexOf(".com/"); - if (index != -1 && index + 5 < imageUrl.length()) { - String key = imageUrl.substring(index + 5); - return URLDecoder.decode(key, StandardCharsets.UTF_8); - } - - return imageUrl; - } -} diff --git a/src/main/java/com/ClubAccount_BE/user/application/port/out/UserImageKeyExtractorPort.java b/src/main/java/com/ClubAccount_BE/user/application/port/out/UserImageKeyExtractorPort.java deleted file mode 100644 index 5a46842..0000000 --- a/src/main/java/com/ClubAccount_BE/user/application/port/out/UserImageKeyExtractorPort.java +++ /dev/null @@ -1,8 +0,0 @@ -package com.ClubAccount_BE.user.application.port.out; - -/** - * S3 이미지 URL에서 객체 키(key)를 추출하는 포트 - */ -public interface UserImageKeyExtractorPort { - String extract(String imageUrl); -} From 724dcc16a9d43a703941956e6340090d1241321b Mon Sep 17 00:00:00 2001 From: JinHyeon Date: Wed, 28 May 2025 05:39:38 +0900 Subject: [PATCH 09/14] =?UTF-8?q?TB-31/feat:=20=EA=B3=B5=EC=9A=A9=20S3=20?= =?UTF-8?q?=ED=81=B4=EB=9E=98=EC=8A=A4=20=EC=83=9D=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../core/s3/DefaultS3KeyExtractor.java | 19 +++++++++++++++++++ .../core/s3/DefaultS3UrlBuilder.java | 19 +++++++++++++++++++ .../core/s3/S3KeyExtractor.java | 5 +++++ .../ClubAccount_BE/core/s3/S3UrlBuilder.java | 5 +++++ 4 files changed, 48 insertions(+) create mode 100644 src/main/java/com/ClubAccount_BE/core/s3/DefaultS3KeyExtractor.java create mode 100644 src/main/java/com/ClubAccount_BE/core/s3/DefaultS3UrlBuilder.java create mode 100644 src/main/java/com/ClubAccount_BE/core/s3/S3KeyExtractor.java create mode 100644 src/main/java/com/ClubAccount_BE/core/s3/S3UrlBuilder.java diff --git a/src/main/java/com/ClubAccount_BE/core/s3/DefaultS3KeyExtractor.java b/src/main/java/com/ClubAccount_BE/core/s3/DefaultS3KeyExtractor.java new file mode 100644 index 0000000..aa1b603 --- /dev/null +++ b/src/main/java/com/ClubAccount_BE/core/s3/DefaultS3KeyExtractor.java @@ -0,0 +1,19 @@ +package com.ClubAccount_BE.core.s3; + +import org.springframework.stereotype.Component; + +import java.net.URL; +import java.net.URLDecoder; +@Component +public class DefaultS3KeyExtractor implements S3KeyExtractor { + @Override + public String extractKey(String url) { + if (url == null || url.isBlank()) return null; + try { + String path = new URL(url).getPath(); // /bucket/key.jpg + return URLDecoder.decode(path.substring(1), "UTF-8"); // "bucket/key.jpg" + } catch (Exception e) { + return null; + } + } +} \ No newline at end of file diff --git a/src/main/java/com/ClubAccount_BE/core/s3/DefaultS3UrlBuilder.java b/src/main/java/com/ClubAccount_BE/core/s3/DefaultS3UrlBuilder.java new file mode 100644 index 0000000..1bbd2c3 --- /dev/null +++ b/src/main/java/com/ClubAccount_BE/core/s3/DefaultS3UrlBuilder.java @@ -0,0 +1,19 @@ +package com.ClubAccount_BE.core.s3; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +@Component +public class DefaultS3UrlBuilder implements S3UrlBuilder { + + @Value("${cloud.aws.s3.bucket}") + private String bucket; + + @Value("${cloud.aws.region.static}") + private String region; + + @Override + public String toUrl(String key) { + return "https://" + bucket + ".s3." + region + ".amazonaws.com/" + key; + } +} \ No newline at end of file diff --git a/src/main/java/com/ClubAccount_BE/core/s3/S3KeyExtractor.java b/src/main/java/com/ClubAccount_BE/core/s3/S3KeyExtractor.java new file mode 100644 index 0000000..97d9a16 --- /dev/null +++ b/src/main/java/com/ClubAccount_BE/core/s3/S3KeyExtractor.java @@ -0,0 +1,5 @@ +package com.ClubAccount_BE.core.s3; + +public interface S3KeyExtractor { + String extractKey(String url); +} diff --git a/src/main/java/com/ClubAccount_BE/core/s3/S3UrlBuilder.java b/src/main/java/com/ClubAccount_BE/core/s3/S3UrlBuilder.java new file mode 100644 index 0000000..b748123 --- /dev/null +++ b/src/main/java/com/ClubAccount_BE/core/s3/S3UrlBuilder.java @@ -0,0 +1,5 @@ +package com.ClubAccount_BE.core.s3; + +public interface S3UrlBuilder { + String toUrl(String key); +} From 440baf85a431e35e421ccb958a49105e4cb31db2 Mon Sep 17 00:00:00 2001 From: JinHyeon Date: Wed, 28 May 2025 05:40:23 +0900 Subject: [PATCH 10/14] =?UTF-8?q?TB-31/feat:=20=EC=9D=B4=EB=AF=B8=EC=A7=80?= =?UTF-8?q?=20=EC=82=AD=EC=A0=9C=20=EC=8B=A4=ED=8C=A8=20=EC=97=90=EB=9F=AC?= =?UTF-8?q?=EC=BD=94=EB=93=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/com/ClubAccount_BE/core/exception/ErrorCode.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/ClubAccount_BE/core/exception/ErrorCode.java b/src/main/java/com/ClubAccount_BE/core/exception/ErrorCode.java index 39a2932..f431c3a 100644 --- a/src/main/java/com/ClubAccount_BE/core/exception/ErrorCode.java +++ b/src/main/java/com/ClubAccount_BE/core/exception/ErrorCode.java @@ -30,7 +30,8 @@ public enum ErrorCode { RECEIPT_NOT_DELETE("2003", "요청한 영수증을 삭제할 수 없습니다.", HttpStatus.FORBIDDEN), // S3 관련 에러 코드 - S3_UPLOAD_FAIL("3001", "S3에 이미지 업로드 에러입니다.", HttpStatus.INTERNAL_SERVER_ERROR); + S3_UPLOAD_FAIL("3001", "S3에 이미지 업로드 에러입니다.", HttpStatus.INTERNAL_SERVER_ERROR), + S3_DELETE_FAIL("3002", "S3에 이미지 삭제 에러입니다.", HttpStatus.INTERNAL_SERVER_ERROR); private final String code; private final String message; From 6e77c0d017a3ddb74792c58f55c9508503023806 Mon Sep 17 00:00:00 2001 From: JinHyeon Date: Wed, 28 May 2025 05:41:16 +0900 Subject: [PATCH 11/14] =?UTF-8?q?TB-31/refactor:=20=ED=9A=8C=EC=9B=90=20?= =?UTF-8?q?=EC=82=AD=EC=A0=9C=EC=8B=9C=20S3=20=EC=9D=B4=EB=AF=B8=EC=A7=80?= =?UTF-8?q?=20=EC=A0=9C=EA=B1=B0=20=EA=B5=AC=EC=A1=B0=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../adapter/out/ReceiptRepositoryAdapter.java | 8 ++++ .../application/port/out/FindReceiptPort.java | 2 + .../ProfileImageRepositoryAdapter.java | 29 +++++++---- .../out/delete/DeleteProfileImagePort.java | 5 ++ .../service/delete/DeleteUserService.java | 48 +++---------------- 5 files changed, 42 insertions(+), 50 deletions(-) create mode 100644 src/main/java/com/ClubAccount_BE/user/application/port/out/delete/DeleteProfileImagePort.java diff --git a/src/main/java/com/ClubAccount_BE/receipt/adapter/out/ReceiptRepositoryAdapter.java b/src/main/java/com/ClubAccount_BE/receipt/adapter/out/ReceiptRepositoryAdapter.java index d18201b..eac9670 100644 --- a/src/main/java/com/ClubAccount_BE/receipt/adapter/out/ReceiptRepositoryAdapter.java +++ b/src/main/java/com/ClubAccount_BE/receipt/adapter/out/ReceiptRepositoryAdapter.java @@ -69,6 +69,14 @@ public List getReceiptMonthlyExpenseList(User user, int year) { .toList(); } + @Override + public List getAllReceipts(User user) { + return receiptRepository.findAllByUserId(user.getId()) + .stream() + .map(ReceiptMapper::toDomain) + .toList(); + } + @Override public List getReceiptCategoryList(User user) { return receiptRepository diff --git a/src/main/java/com/ClubAccount_BE/receipt/application/port/out/FindReceiptPort.java b/src/main/java/com/ClubAccount_BE/receipt/application/port/out/FindReceiptPort.java index 6b2a06f..bd7c79c 100644 --- a/src/main/java/com/ClubAccount_BE/receipt/application/port/out/FindReceiptPort.java +++ b/src/main/java/com/ClubAccount_BE/receipt/application/port/out/FindReceiptPort.java @@ -21,4 +21,6 @@ Page getReceiptList( Receipt getReceipt(User user, Long receiptId); List getReceiptMonthlyExpenseList(User user, int year); + + List getAllReceipts(User user); } diff --git a/src/main/java/com/ClubAccount_BE/user/adapter/out/persistence/ProfileImageRepositoryAdapter.java b/src/main/java/com/ClubAccount_BE/user/adapter/out/persistence/ProfileImageRepositoryAdapter.java index ba5f510..3c70e60 100644 --- a/src/main/java/com/ClubAccount_BE/user/adapter/out/persistence/ProfileImageRepositoryAdapter.java +++ b/src/main/java/com/ClubAccount_BE/user/adapter/out/persistence/ProfileImageRepositoryAdapter.java @@ -2,7 +2,10 @@ import com.ClubAccount_BE.core.exception.ApiException; import com.ClubAccount_BE.core.exception.ErrorCode; +import com.ClubAccount_BE.core.s3.S3KeyExtractor; +import com.ClubAccount_BE.core.s3.S3UrlBuilder; import com.ClubAccount_BE.user.application.port.out.UploadProfileImagePort; +import com.ClubAccount_BE.user.application.port.out.delete.DeleteProfileImagePort; import lombok.RequiredArgsConstructor; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Component; @@ -18,39 +21,47 @@ @Component @RequiredArgsConstructor -public class ProfileImageRepositoryAdapter implements UploadProfileImagePort { +public class ProfileImageRepositoryAdapter implements UploadProfileImagePort, DeleteProfileImagePort { private final S3Client amazonS3; + private final S3KeyExtractor keyExtractor; + private final S3UrlBuilder urlBuilder; @Value("${cloud.aws.s3.bucket}") private String bucket; @Override public String uploadProfileImage(Long userId, MultipartFile image) { - String imageName = createImageName(image.getOriginalFilename()); - try { PutObjectRequest request = PutObjectRequest.builder() .bucket(bucket) .key(imageName) .contentType(image.getContentType()) .build(); - amazonS3.putObject(request, RequestBody.fromInputStream(image.getInputStream(), image.getSize()) ); - } catch (IOException e) { throw new ApiException(ErrorCode.S3_UPLOAD_FAIL, e.getMessage()); } - - return amazonS3.utilities() - .getUrl(b -> b.bucket(bucket).key(imageName)) - .toExternalForm(); + return urlBuilder.toUrl(imageName); } private String createImageName(String originalFilename) { return UUID.randomUUID() + IMAGE_KEY_DELIMITER + originalFilename; } + + @Override + public void deleteImages(String profileImage) { + try { + String key = keyExtractor.extractKey(profileImage); + amazonS3.deleteObject(builder -> builder + .bucket(bucket) + .key(key) + ); + } catch (Exception e) { + throw new ApiException(ErrorCode.S3_DELETE_FAIL); + } + } } \ No newline at end of file diff --git a/src/main/java/com/ClubAccount_BE/user/application/port/out/delete/DeleteProfileImagePort.java b/src/main/java/com/ClubAccount_BE/user/application/port/out/delete/DeleteProfileImagePort.java new file mode 100644 index 0000000..268dc4d --- /dev/null +++ b/src/main/java/com/ClubAccount_BE/user/application/port/out/delete/DeleteProfileImagePort.java @@ -0,0 +1,5 @@ +package com.ClubAccount_BE.user.application.port.out.delete; + +public interface DeleteProfileImagePort { + void deleteImages(String profileImage); +} diff --git a/src/main/java/com/ClubAccount_BE/user/application/service/delete/DeleteUserService.java b/src/main/java/com/ClubAccount_BE/user/application/service/delete/DeleteUserService.java index c74b9b7..33b5788 100644 --- a/src/main/java/com/ClubAccount_BE/user/application/service/delete/DeleteUserService.java +++ b/src/main/java/com/ClubAccount_BE/user/application/service/delete/DeleteUserService.java @@ -1,24 +1,18 @@ package com.ClubAccount_BE.user.application.service.delete; -import com.ClubAccount_BE.core.exception.ApiException; -import com.ClubAccount_BE.core.exception.ErrorCode; +import com.ClubAccount_BE.receipt.application.port.out.DeleteReceiptImagePort; import com.ClubAccount_BE.receipt.application.port.out.FindReceiptPort; import com.ClubAccount_BE.receipt.domain.Receipt; import com.ClubAccount_BE.user.application.port.in.delete.DeleteUserUseCase; import com.ClubAccount_BE.user.application.port.out.UserPort; -import com.ClubAccount_BE.user.application.port.out.UserImageKeyExtractorPort; +import com.ClubAccount_BE.user.application.port.out.delete.DeleteProfileImagePort; import com.ClubAccount_BE.user.domain.User; 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.services.s3.S3Client; -import software.amazon.awssdk.services.s3.model.Delete; -import software.amazon.awssdk.services.s3.model.DeleteObjectsRequest; -import software.amazon.awssdk.services.s3.model.ObjectIdentifier; -import java.util.ArrayList; import java.util.List; import java.util.Objects; @@ -29,51 +23,23 @@ public class DeleteUserService implements DeleteUserUseCase { private final UserPort userPort; private final FindReceiptPort findReceiptPort; - private final UserImageKeyExtractorPort userImageKeyExtractorPort; - private final S3Client s3Client; + private final DeleteReceiptImagePort deleteReceiptImagePort; + private final DeleteProfileImagePort deleteProfileImagePort; @Value("${cloud.aws.s3.bucket}") private String bucketName; @Override public void deleteUser(User user) { - List allImageKeys = new ArrayList<>(); - // 프로필 이미지 - if (user.getProfileUrl() != null) { - String key = userImageKeyExtractorPort.extract(user.getProfileUrl()); - if (key != null) { - allImageKeys.add(key); - } - } + deleteProfileImagePort.deleteImages(user.getProfileUrl()); - // 영수증 이미지 - List receipts = findReceiptPort.getReceiptCategoryList(user); + List receipts = findReceiptPort.getAllReceipts(user); List receiptImageKeys = receipts.stream() .map(Receipt::getReceiptImageUrl) .filter(Objects::nonNull) - .map(userImageKeyExtractorPort::extract) - .filter(Objects::nonNull) .toList(); - allImageKeys.addAll(receiptImageKeys); - - // S3 이미지 삭제 - if (!allImageKeys.isEmpty()) { - List s3Objects = allImageKeys.stream() - .map(key -> ObjectIdentifier.builder().key(key).build()) - .toList(); - - DeleteObjectsRequest request = DeleteObjectsRequest.builder() - .bucket(bucketName) - .delete(Delete.builder().objects(s3Objects).build()) - .build(); - - try { - s3Client.deleteObjects(request); - } catch (Exception e) { - throw new ApiException(ErrorCode.S3_UPLOAD_FAIL, e.getMessage()); - } - } + deleteReceiptImagePort.deleteImages(receiptImageKeys); // 회원 삭제 userPort.delete(user); From 01cdc41cce4bff63b99b64c4cbe74c754cc37c75 Mon Sep 17 00:00:00 2001 From: JinHyeon Date: Wed, 28 May 2025 05:52:38 +0900 Subject: [PATCH 12/14] =?UTF-8?q?TB-31/chore:=20=EC=82=AC=EC=9A=A9?= =?UTF-8?q?=ED=95=98=EC=A7=80=20=EC=95=8A=EB=8A=94=20=ED=95=84=EB=93=9C=20?= =?UTF-8?q?bucketName=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../user/application/service/delete/DeleteUserService.java | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/main/java/com/ClubAccount_BE/user/application/service/delete/DeleteUserService.java b/src/main/java/com/ClubAccount_BE/user/application/service/delete/DeleteUserService.java index 33b5788..5736b0a 100644 --- a/src/main/java/com/ClubAccount_BE/user/application/service/delete/DeleteUserService.java +++ b/src/main/java/com/ClubAccount_BE/user/application/service/delete/DeleteUserService.java @@ -9,7 +9,6 @@ import com.ClubAccount_BE.user.application.port.out.delete.DeleteProfileImagePort; import com.ClubAccount_BE.user.domain.User; import lombok.RequiredArgsConstructor; -import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -26,9 +25,6 @@ public class DeleteUserService implements DeleteUserUseCase { private final DeleteReceiptImagePort deleteReceiptImagePort; private final DeleteProfileImagePort deleteProfileImagePort; - @Value("${cloud.aws.s3.bucket}") - private String bucketName; - @Override public void deleteUser(User user) { From 245d78c16ebe9288ef08ea45bcfc4ec500f337ad Mon Sep 17 00:00:00 2001 From: JinHyeon Date: Wed, 28 May 2025 05:57:08 +0900 Subject: [PATCH 13/14] =?UTF-8?q?TB-31/refactor:=20Java=2020=20=EB=8C=80?= =?UTF-8?q?=EC=9D=91=EC=9D=84=20=EC=9C=84=ED=95=B4=20URLDecoder=EC=97=90?= =?UTF-8?q?=20StandardCharsets.UTF=5F8=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/ClubAccount_BE/core/s3/DefaultS3KeyExtractor.java | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/ClubAccount_BE/core/s3/DefaultS3KeyExtractor.java b/src/main/java/com/ClubAccount_BE/core/s3/DefaultS3KeyExtractor.java index aa1b603..3270e2d 100644 --- a/src/main/java/com/ClubAccount_BE/core/s3/DefaultS3KeyExtractor.java +++ b/src/main/java/com/ClubAccount_BE/core/s3/DefaultS3KeyExtractor.java @@ -4,14 +4,16 @@ import java.net.URL; import java.net.URLDecoder; +import java.nio.charset.StandardCharsets; + @Component public class DefaultS3KeyExtractor implements S3KeyExtractor { @Override public String extractKey(String url) { if (url == null || url.isBlank()) return null; try { - String path = new URL(url).getPath(); // /bucket/key.jpg - return URLDecoder.decode(path.substring(1), "UTF-8"); // "bucket/key.jpg" + String path = new URL(url).getPath(); + return URLDecoder.decode(path.substring(1), StandardCharsets.UTF_8); } catch (Exception e) { return null; } From 40ab6600a7b86b14ae317a63c762e8518ad8fa9f Mon Sep 17 00:00:00 2001 From: JinHyeon Date: Wed, 28 May 2025 06:02:46 +0900 Subject: [PATCH 14/14] =?UTF-8?q?TB-31/feat:=20=ED=94=84=EB=A1=9C=ED=95=84?= =?UTF-8?q?=20=EC=9D=B4=EB=AF=B8=EC=A7=80=20=EC=88=98=EC=A0=95=EC=8B=9C=20?= =?UTF-8?q?=EA=B8=B0=EC=A1=B4=20s3=20=ED=94=84=EB=A1=9C=ED=95=84=20?= =?UTF-8?q?=EC=9D=B4=EB=AF=B8=EC=A7=80=20=EC=82=AD=EC=A0=9C=EA=B8=B0?= =?UTF-8?q?=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 --- .../user/application/service/update/ProfileService.java | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/main/java/com/ClubAccount_BE/user/application/service/update/ProfileService.java b/src/main/java/com/ClubAccount_BE/user/application/service/update/ProfileService.java index e51e988..f96c115 100644 --- a/src/main/java/com/ClubAccount_BE/user/application/service/update/ProfileService.java +++ b/src/main/java/com/ClubAccount_BE/user/application/service/update/ProfileService.java @@ -6,6 +6,7 @@ import com.ClubAccount_BE.user.application.port.in.update.ProfileUseCase; import com.ClubAccount_BE.user.application.port.out.UploadProfileImagePort; import com.ClubAccount_BE.user.application.port.out.UserPort; +import com.ClubAccount_BE.user.application.port.out.delete.DeleteProfileImagePort; import com.ClubAccount_BE.user.domain.User; import lombok.RequiredArgsConstructor; import org.springframework.security.crypto.password.PasswordEncoder; @@ -21,6 +22,7 @@ public class ProfileService implements ProfileUseCase { private final UploadProfileImagePort uploadProfileImagePort; + private final DeleteProfileImagePort deleteProfileImagePort; private final UserPort userPort; private final PasswordEncoder passwordEncoder; @@ -37,6 +39,11 @@ public void updateProfile(User user, MultipartFile profileImage, ProfileUpdateRe } if (profileImage != null && !profileImage.isEmpty()) { + String existingUrl = user.getProfileUrl(); + if (existingUrl != null && !existingUrl.isBlank()) { + deleteProfileImagePort.deleteImages(existingUrl); + } + String url = uploadProfileImagePort.uploadProfileImage(user.getId(), profileImage); user.updateProfileUrl(url); }