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 30ea401..39a2932 100644 --- a/src/main/java/com/ClubAccount_BE/core/exception/ErrorCode.java +++ b/src/main/java/com/ClubAccount_BE/core/exception/ErrorCode.java @@ -27,6 +27,7 @@ public enum ErrorCode { // 영수증 관련 에러 코드 RECEIPT_INVALID_START_DATE("2001", "시작일은 종료일보다 빠르거나 같아야 합니다.", HttpStatus.BAD_REQUEST), RECEIPT_NOT_FOUND("2002", "해당 영수증을 찾을 수 없습니다.", HttpStatus.NOT_FOUND), + RECEIPT_NOT_DELETE("2003", "요청한 영수증을 삭제할 수 없습니다.", HttpStatus.FORBIDDEN), // S3 관련 에러 코드 S3_UPLOAD_FAIL("3001", "S3에 이미지 업로드 에러입니다.", HttpStatus.INTERNAL_SERVER_ERROR); diff --git a/src/main/java/com/ClubAccount_BE/core/meta/LoginUser.java b/src/main/java/com/ClubAccount_BE/core/meta/LoginUser.java index b89d097..e6a8a30 100644 --- a/src/main/java/com/ClubAccount_BE/core/meta/LoginUser.java +++ b/src/main/java/com/ClubAccount_BE/core/meta/LoginUser.java @@ -1,5 +1,6 @@ package com.ClubAccount_BE.core.meta; +import io.swagger.v3.oas.annotations.Parameter; import java.lang.annotation.Documented; import java.lang.annotation.ElementType; import java.lang.annotation.Retention; @@ -9,6 +10,7 @@ @Target({ElementType.PARAMETER, ElementType.ANNOTATION_TYPE}) @Retention(RetentionPolicy.RUNTIME) @Documented +@Parameter(hidden = true) public @interface LoginUser { } diff --git a/src/main/java/com/ClubAccount_BE/receipt/adapter/in/web/CreateReceiptController.java b/src/main/java/com/ClubAccount_BE/receipt/adapter/in/web/CreateReceiptController.java index 0a813d2..16201b2 100644 --- a/src/main/java/com/ClubAccount_BE/receipt/adapter/in/web/CreateReceiptController.java +++ b/src/main/java/com/ClubAccount_BE/receipt/adapter/in/web/CreateReceiptController.java @@ -20,7 +20,7 @@ public class CreateReceiptController implements CreateReceiptApi { private final CreateReceiptUseCase createReceiptUseCase; - @PostMapping("/create") + @PostMapping public Long createReceipt( @LoginUser User user, @RequestPart(value = "image", required = false) MultipartFile image, diff --git a/src/main/java/com/ClubAccount_BE/receipt/adapter/in/web/DeleteReceiptController.java b/src/main/java/com/ClubAccount_BE/receipt/adapter/in/web/DeleteReceiptController.java new file mode 100644 index 0000000..c674d8f --- /dev/null +++ b/src/main/java/com/ClubAccount_BE/receipt/adapter/in/web/DeleteReceiptController.java @@ -0,0 +1,31 @@ +package com.ClubAccount_BE.receipt.adapter.in.web; + +import com.ClubAccount_BE.core.meta.LoginUser; +import com.ClubAccount_BE.receipt.adapter.in.web.api.DeleteReceiptApi; +import com.ClubAccount_BE.receipt.application.port.in.DeleteReceiptUseCase; +import com.ClubAccount_BE.user.domain.User; +import jakarta.validation.constraints.NotEmpty; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/v1/receipts") +public class DeleteReceiptController implements DeleteReceiptApi { + + private final DeleteReceiptUseCase deleteReceiptUseCase; + + @DeleteMapping + public ResponseEntity deleteReceiptList( + @LoginUser User user, + @RequestParam @NotEmpty List receiptIds + ) { + deleteReceiptUseCase.deleteReceiptList(user, receiptIds); + return ResponseEntity.noContent().build(); + } +} diff --git a/src/main/java/com/ClubAccount_BE/receipt/adapter/in/web/api/DeleteReceiptApi.java b/src/main/java/com/ClubAccount_BE/receipt/adapter/in/web/api/DeleteReceiptApi.java new file mode 100644 index 0000000..b008cf1 --- /dev/null +++ b/src/main/java/com/ClubAccount_BE/receipt/adapter/in/web/api/DeleteReceiptApi.java @@ -0,0 +1,20 @@ +package com.ClubAccount_BE.receipt.adapter.in.web.api; + +import com.ClubAccount_BE.core.meta.LoginUser; +import com.ClubAccount_BE.user.domain.User; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.constraints.NotEmpty; +import java.util.List; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.RequestParam; + +@Tag(name = "Delete Receipt", description = "영수증 삭제 API") +public interface DeleteReceiptApi { + + @Operation(summary = "영수증 삭제", description = "등록된 영수증 정보를 삭제한다.") + ResponseEntity deleteReceiptList( + @LoginUser User user, + @RequestParam @NotEmpty List receiptIds + ); +} diff --git a/src/main/java/com/ClubAccount_BE/receipt/adapter/out/ReceiptImageRepositoryAdapter.java b/src/main/java/com/ClubAccount_BE/receipt/adapter/out/ReceiptImageRepositoryAdapter.java index 88a0d81..3afcb62 100644 --- a/src/main/java/com/ClubAccount_BE/receipt/adapter/out/ReceiptImageRepositoryAdapter.java +++ b/src/main/java/com/ClubAccount_BE/receipt/adapter/out/ReceiptImageRepositoryAdapter.java @@ -1,12 +1,14 @@ package com.ClubAccount_BE.receipt.adapter.out; - import static com.ClubAccount_BE.core.constant.CommonConstant.IMAGE_KEY_DELIMITER; import static com.ClubAccount_BE.core.exception.ErrorCode.S3_UPLOAD_FAIL; import com.ClubAccount_BE.core.exception.ApiException; -import com.ClubAccount_BE.receipt.application.port.out.UploadReceiptPort; +import com.ClubAccount_BE.receipt.application.port.out.DeleteReceiptImagePort; +import com.ClubAccount_BE.receipt.application.port.out.UploadReceiptImagePort; import java.io.IOException; +import java.net.URI; +import java.util.List; import java.util.UUID; import lombok.RequiredArgsConstructor; import org.springframework.beans.factory.annotation.Value; @@ -14,11 +16,12 @@ import org.springframework.web.multipart.MultipartFile; import software.amazon.awssdk.core.sync.RequestBody; import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.model.DeleteObjectRequest; import software.amazon.awssdk.services.s3.model.PutObjectRequest; @Component @RequiredArgsConstructor -public class ReceiptImageRepositoryAdapter implements UploadReceiptPort { +public class ReceiptImageRepositoryAdapter implements UploadReceiptImagePort, DeleteReceiptImagePort { private final S3Client amazonS3; @@ -48,6 +51,19 @@ public String uploadReceipt(MultipartFile image) { return getImageUrl(imageName); } + // TODO: S3에서 이미지 삭제 로직 비동기 처리 + @Override + public void deleteImages(List receiptImage) { + receiptImage.forEach(url -> { + String key = extractKey(url); + DeleteObjectRequest request = DeleteObjectRequest.builder() + .bucket(bucket) + .key(key) + .build(); + amazonS3.deleteObject(request); + }); + } + private String createImageName(String originalFilename) { return UUID.randomUUID() + IMAGE_KEY_DELIMITER + originalFilename; } @@ -57,4 +73,8 @@ private String getImageUrl(String fileName) { .getUrl(builder -> builder.bucket(bucket).key(fileName)) .toExternalForm(); } + + private String extractKey(String url) { + return URI.create(url).getPath().substring(1); + } } 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 90727f5..d18201b 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 @@ -1,11 +1,13 @@ package com.ClubAccount_BE.receipt.adapter.out; +import static com.ClubAccount_BE.core.exception.ErrorCode.RECEIPT_NOT_DELETE; import static com.ClubAccount_BE.core.exception.ErrorCode.RECEIPT_NOT_FOUND; import com.ClubAccount_BE.core.exception.ApiException; import com.ClubAccount_BE.receipt.adapter.out.persistence.entity.ReceiptEntity; import com.ClubAccount_BE.receipt.adapter.out.persistence.repository.ReceiptRepository; import com.ClubAccount_BE.receipt.application.port.out.CreateReceiptPort; +import com.ClubAccount_BE.receipt.application.port.out.DeleteReceiptPort; import com.ClubAccount_BE.receipt.application.port.out.FindReceiptPort; import com.ClubAccount_BE.receipt.application.port.out.UpdateReceiptPort; import com.ClubAccount_BE.receipt.domain.Receipt; @@ -23,7 +25,7 @@ @Component @RequiredArgsConstructor public class ReceiptRepositoryAdapter - implements CreateReceiptPort, FindReceiptPort, UpdateReceiptPort { + implements CreateReceiptPort, FindReceiptPort, UpdateReceiptPort, DeleteReceiptPort { private final ReceiptRepository receiptRepository; @@ -91,4 +93,19 @@ public Long updateReceipt( .forEach(receiptEntity::addReceiptItem); return receiptEntity.getId(); } + + @Override + public List deleteReceiptList(User user, List receiptIds) { + List receipts = receiptRepository.findAllById(receiptIds); + + boolean hasInvalidOwner = receipts.stream() + .anyMatch(receipt -> !receipt.getUser().getId().equals(user.getId())); + + if (hasInvalidOwner || receipts.size() != receiptIds.size()) { + throw new ApiException(RECEIPT_NOT_DELETE); + } + + receiptRepository.deleteAll(receipts); + return receipts.stream().map(ReceiptMapper::toDomain).toList(); + } } diff --git a/src/main/java/com/ClubAccount_BE/receipt/application/port/in/DeleteReceiptUseCase.java b/src/main/java/com/ClubAccount_BE/receipt/application/port/in/DeleteReceiptUseCase.java new file mode 100644 index 0000000..77b4e5c --- /dev/null +++ b/src/main/java/com/ClubAccount_BE/receipt/application/port/in/DeleteReceiptUseCase.java @@ -0,0 +1,9 @@ +package com.ClubAccount_BE.receipt.application.port.in; + +import com.ClubAccount_BE.user.domain.User; +import java.util.List; + +public interface DeleteReceiptUseCase { + + void deleteReceiptList(User user, List receiptIds); +} diff --git a/src/main/java/com/ClubAccount_BE/receipt/application/port/out/DeleteReceiptImagePort.java b/src/main/java/com/ClubAccount_BE/receipt/application/port/out/DeleteReceiptImagePort.java new file mode 100644 index 0000000..6ef64e5 --- /dev/null +++ b/src/main/java/com/ClubAccount_BE/receipt/application/port/out/DeleteReceiptImagePort.java @@ -0,0 +1,8 @@ +package com.ClubAccount_BE.receipt.application.port.out; + +import java.util.List; + +public interface DeleteReceiptImagePort { + + void deleteImages(List receiptImage); +} diff --git a/src/main/java/com/ClubAccount_BE/receipt/application/port/out/DeleteReceiptPort.java b/src/main/java/com/ClubAccount_BE/receipt/application/port/out/DeleteReceiptPort.java new file mode 100644 index 0000000..4b0448d --- /dev/null +++ b/src/main/java/com/ClubAccount_BE/receipt/application/port/out/DeleteReceiptPort.java @@ -0,0 +1,10 @@ +package com.ClubAccount_BE.receipt.application.port.out; + +import com.ClubAccount_BE.receipt.domain.Receipt; +import com.ClubAccount_BE.user.domain.User; +import java.util.List; + +public interface DeleteReceiptPort { + + List deleteReceiptList(User user, List receiptIds); +} diff --git a/src/main/java/com/ClubAccount_BE/receipt/application/port/out/UploadReceiptPort.java b/src/main/java/com/ClubAccount_BE/receipt/application/port/out/UploadReceiptImagePort.java similarity index 79% rename from src/main/java/com/ClubAccount_BE/receipt/application/port/out/UploadReceiptPort.java rename to src/main/java/com/ClubAccount_BE/receipt/application/port/out/UploadReceiptImagePort.java index 7bbf214..b9eadae 100644 --- a/src/main/java/com/ClubAccount_BE/receipt/application/port/out/UploadReceiptPort.java +++ b/src/main/java/com/ClubAccount_BE/receipt/application/port/out/UploadReceiptImagePort.java @@ -2,7 +2,7 @@ import org.springframework.web.multipart.MultipartFile; -public interface UploadReceiptPort { +public interface UploadReceiptImagePort { String uploadReceipt(MultipartFile image); } diff --git a/src/main/java/com/ClubAccount_BE/receipt/application/service/CreateReceiptService.java b/src/main/java/com/ClubAccount_BE/receipt/application/service/CreateReceiptService.java index 826e905..e6e29bd 100644 --- a/src/main/java/com/ClubAccount_BE/receipt/application/service/CreateReceiptService.java +++ b/src/main/java/com/ClubAccount_BE/receipt/application/service/CreateReceiptService.java @@ -5,7 +5,7 @@ import com.ClubAccount_BE.receipt.adapter.in.web.dto.request.ReceiptRequest; import com.ClubAccount_BE.receipt.application.port.in.CreateReceiptUseCase; import com.ClubAccount_BE.receipt.application.port.out.CreateReceiptPort; -import com.ClubAccount_BE.receipt.application.port.out.UploadReceiptPort; +import com.ClubAccount_BE.receipt.application.port.out.UploadReceiptImagePort; import com.ClubAccount_BE.receipt.domain.Receipt; import com.ClubAccount_BE.receipt.domain.ReceiptItem; import com.ClubAccount_BE.receipt.domain.service.ReceiptEditor; @@ -23,7 +23,7 @@ public class CreateReceiptService implements CreateReceiptUseCase { private final CreateReceiptPort createReceiptPort; - private final UploadReceiptPort uploadReceiptPort; + private final UploadReceiptImagePort uploadReceiptImagePort; private final ReceiptEditor receiptEditor; private final ReceiptItemEditor receiptItemEditor; @@ -40,7 +40,7 @@ public Long createReceipt( receiptRequest.date(), receiptRequest.amount(), receiptRequest.etc(), - image == null ? DEFAULT_IMAGE : uploadReceiptPort.uploadReceipt(image) + image == null ? DEFAULT_IMAGE : uploadReceiptImagePort.uploadReceipt(image) ); List receiptItems = receiptItemEditor.toReceiptItems(receiptRequest, receipt); boolean isAmountMatched = receiptEditor.checkAmountMatch(receipt, receiptItems); diff --git a/src/main/java/com/ClubAccount_BE/receipt/application/service/DeleteReceiptService.java b/src/main/java/com/ClubAccount_BE/receipt/application/service/DeleteReceiptService.java new file mode 100644 index 0000000..9ebefee --- /dev/null +++ b/src/main/java/com/ClubAccount_BE/receipt/application/service/DeleteReceiptService.java @@ -0,0 +1,29 @@ +package com.ClubAccount_BE.receipt.application.service; + +import com.ClubAccount_BE.receipt.application.port.in.DeleteReceiptUseCase; +import com.ClubAccount_BE.receipt.application.port.out.DeleteReceiptImagePort; +import com.ClubAccount_BE.receipt.application.port.out.DeleteReceiptPort; +import com.ClubAccount_BE.receipt.domain.Receipt; +import com.ClubAccount_BE.receipt.domain.service.ReceiptEditor; +import com.ClubAccount_BE.user.domain.User; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Transactional +public class DeleteReceiptService implements DeleteReceiptUseCase { + + private final DeleteReceiptPort deleteReceiptPort; + private final DeleteReceiptImagePort deleteReceiptImagePort; + private final ReceiptEditor receiptEditor; + + @Override + public void deleteReceiptList(User user, List receiptIds) { + List receiptList = deleteReceiptPort.deleteReceiptList(user, receiptIds); + List receiptImage = receiptEditor.deleteReceiptImage(receiptList); + deleteReceiptImagePort.deleteImages(receiptImage); + } +} diff --git a/src/main/java/com/ClubAccount_BE/receipt/domain/service/ReceiptEditor.java b/src/main/java/com/ClubAccount_BE/receipt/domain/service/ReceiptEditor.java index 8972398..330261a 100644 --- a/src/main/java/com/ClubAccount_BE/receipt/domain/service/ReceiptEditor.java +++ b/src/main/java/com/ClubAccount_BE/receipt/domain/service/ReceiptEditor.java @@ -1,5 +1,6 @@ package com.ClubAccount_BE.receipt.domain.service; +import static com.ClubAccount_BE.core.constant.CommonConstant.DEFAULT_IMAGE; import static com.ClubAccount_BE.receipt.domain.type.ReceiptCategory.GROUP_DINING; import static com.ClubAccount_BE.receipt.domain.type.ReceiptCategory.OTHER; import static com.ClubAccount_BE.receipt.domain.type.ReceiptCategory.SUBSCRIPTION; @@ -72,4 +73,14 @@ public List calculateMonthlyExpense( monthlyExpense.getOrDefault(month, BigDecimal.ZERO))) .collect(Collectors.toList()); } + + /** + * 영수증 이미지 삭제 시 필요한 URL 리스트 생성 + */ + public List deleteReceiptImage(List receiptList) { + return receiptList.stream() + .map(Receipt::getReceiptImageUrl) + .filter(url -> url != null && !url.equals(DEFAULT_IMAGE)) + .toList(); + } } diff --git a/src/test/java/com/ClubAccount_BE/receipt/application/service/CreateReceiptServiceTest.java b/src/test/java/com/ClubAccount_BE/receipt/application/service/CreateReceiptServiceTest.java index 08adb00..4258503 100644 --- a/src/test/java/com/ClubAccount_BE/receipt/application/service/CreateReceiptServiceTest.java +++ b/src/test/java/com/ClubAccount_BE/receipt/application/service/CreateReceiptServiceTest.java @@ -1,7 +1,7 @@ package com.ClubAccount_BE.receipt.application.service; import com.ClubAccount_BE.receipt.application.port.out.CreateReceiptPort; -import com.ClubAccount_BE.receipt.application.port.out.UploadReceiptPort; +import com.ClubAccount_BE.receipt.application.port.out.UploadReceiptImagePort; import com.ClubAccount_BE.user.domain.User; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.extension.ExtendWith; @@ -16,7 +16,7 @@ class CreateReceiptServiceTest { private CreateReceiptPort createReceiptPort; @Mock - private UploadReceiptPort uploadReceiptPort; + private UploadReceiptImagePort uploadReceiptImagePort; @InjectMocks private CreateReceiptService createReceiptService;