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; 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..3270e2d --- /dev/null +++ b/src/main/java/com/ClubAccount_BE/core/s3/DefaultS3KeyExtractor.java @@ -0,0 +1,21 @@ +package com.ClubAccount_BE.core.s3; + +import org.springframework.stereotype.Component; + +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(); + return URLDecoder.decode(path.substring(1), StandardCharsets.UTF_8); + } 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); +} 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/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 new file mode 100644 index 0000000..a0f6fe0 --- /dev/null +++ b/src/main/java/com/ClubAccount_BE/user/adapter/in/delete/DeleteUserController.java @@ -0,0 +1,23 @@ +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 implements DeleteUserApi { + + private final DeleteUserUseCase deleteUserUseCase; + + @DeleteMapping + @Override + public void deleteMyAccount(@LoginUser User user) { + deleteUserUseCase.deleteUser(user); + } +} \ No newline at end of file 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 60% 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..3c70e60 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,6 +1,11 @@ -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.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; @@ -16,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 RuntimeException("S3 업로드 실패", 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/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/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<>(); } 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/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 new file mode 100644 index 0000000..5736b0a --- /dev/null +++ b/src/main/java/com/ClubAccount_BE/user/application/service/delete/DeleteUserService.java @@ -0,0 +1,43 @@ +package com.ClubAccount_BE.user.application.service.delete; + +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.delete.DeleteProfileImagePort; +import com.ClubAccount_BE.user.domain.User; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.Objects; + +@Service +@RequiredArgsConstructor +@Transactional +public class DeleteUserService implements DeleteUserUseCase { + + private final UserPort userPort; + private final FindReceiptPort findReceiptPort; + private final DeleteReceiptImagePort deleteReceiptImagePort; + private final DeleteProfileImagePort deleteProfileImagePort; + + @Override + public void deleteUser(User user) { + + deleteProfileImagePort.deleteImages(user.getProfileUrl()); + + List receipts = findReceiptPort.getAllReceipts(user); + List receiptImageKeys = receipts.stream() + .map(Receipt::getReceiptImageUrl) + .filter(Objects::nonNull) + .toList(); + deleteReceiptImagePort.deleteImages(receiptImageKeys); + + // 회원 삭제 + userPort.delete(user); + } +} \ No newline at end of file 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..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,19 +6,23 @@ 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; 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 { private final UploadProfileImagePort uploadProfileImagePort; + private final DeleteProfileImagePort deleteProfileImagePort; private final UserPort userPort; private final PasswordEncoder passwordEncoder; @@ -35,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); }