diff --git a/build.gradle b/build.gradle index 0f10cfc0..306c7975 100644 --- a/build.gradle +++ b/build.gradle @@ -93,6 +93,7 @@ dependencies { // Apple implementation 'com.apple.itunes.storekit:app-store-server-library:3.4.0' + testImplementation 'org.springframework.security:spring-security-test' } tasks.named('test') { diff --git a/src/main/java/umc/th/juinjang/api/apple/controller/AppleController.java b/src/main/java/umc/th/juinjang/api/apple/controller/AppleController.java index 054c9b62..b7d88b79 100644 --- a/src/main/java/umc/th/juinjang/api/apple/controller/AppleController.java +++ b/src/main/java/umc/th/juinjang/api/apple/controller/AppleController.java @@ -1,20 +1,18 @@ package umc.th.juinjang.api.apple.controller; -import org.springframework.http.ResponseEntity; -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; - import com.apple.itunes.storekit.model.Data; import com.apple.itunes.storekit.model.JWSTransactionDecodedPayload; import com.apple.itunes.storekit.model.NotificationTypeV2; import com.apple.itunes.storekit.model.ResponseBodyV2; import com.apple.itunes.storekit.model.ResponseBodyV2DecodedPayload; - import io.swagger.v3.oas.annotations.Operation; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.http.ResponseEntity; +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; import umc.th.juinjang.api.apple.service.AppleService; import umc.th.juinjang.api.pencil.service.PencilCommandService; import umc.th.juinjang.api.pencil.service.PencilQueryService; @@ -25,29 +23,29 @@ @Slf4j public class AppleController { - private final AppleService appleService; - private final PencilQueryService pencilQueryService; - private final PencilCommandService pencilCommandService; + private final AppleService appleService; + private final PencilQueryService pencilQueryService; + private final PencilCommandService pencilCommandService; - @Operation(summary = "애플 서버 알림 API") - @PostMapping("notifications/v2") - public ResponseEntity handleNotificationV2(@RequestBody ResponseBodyV2 requestBody) { - ResponseBodyV2DecodedPayload payload = appleService.getNotificationPayload(requestBody); - NotificationTypeV2 type = payload.getNotificationType(); - log.info("### Notification Type: {}", type); + @Operation(summary = "애플 서버 알림 API") + @PostMapping("notifications/v2") + public ResponseEntity handleNotificationV2(@RequestBody ResponseBodyV2 requestBody) { + ResponseBodyV2DecodedPayload payload = appleService.getNotificationPayload(requestBody); + NotificationTypeV2 type = payload.getNotificationType(); + log.info("### Notification Type: {}", type); - Data data = payload.getData(); - JWSTransactionDecodedPayload transactionPayload = - appleService.getSignedTransactionPayload(data); - if (type == NotificationTypeV2.CONSUMPTION_REQUEST) { - log.info("Apple IAP Consumption Request Notification Received."); - String transactionId = transactionPayload.getTransactionId(); - appleService.sendConsumptionData(transactionId, pencilQueryService.getConsumptionRequest(transactionId)); - } else if (type == NotificationTypeV2.REFUND) { - log.info("Apple IAP ReFund Notification Received."); - String transactionId = transactionPayload.getOriginalTransactionId(); - pencilCommandService.handleRefundPurchase(transactionId); - } - return ResponseEntity.ok().build(); - } + Data data = payload.getData(); + JWSTransactionDecodedPayload transactionPayload = + appleService.getSignedTransactionPayload(data); + if (type == NotificationTypeV2.CONSUMPTION_REQUEST) { + log.info("Apple IAP Consumption Request Notification Received."); + String transactionId = transactionPayload.getTransactionId(); + appleService.sendConsumptionData(transactionId, pencilQueryService.getConsumptionRequest(transactionId)); + } else if (type == NotificationTypeV2.REFUND) { + log.info("Apple IAP ReFund Notification Received."); + String transactionId = transactionPayload.getOriginalTransactionId(); + pencilCommandService.handleRefundPurchase(transactionId); + } + return ResponseEntity.ok().build(); + } } diff --git a/src/main/java/umc/th/juinjang/api/apple/service/AppleService.java b/src/main/java/umc/th/juinjang/api/apple/service/AppleService.java index 3c12e600..95eed8a9 100644 --- a/src/main/java/umc/th/juinjang/api/apple/service/AppleService.java +++ b/src/main/java/umc/th/juinjang/api/apple/service/AppleService.java @@ -1,271 +1,27 @@ package umc.th.juinjang.api.apple.service; -import java.io.IOException; -import java.io.InputStream; -import java.util.HashSet; -import java.util.Set; - -import org.springframework.beans.factory.annotation.Value; -import org.springframework.context.annotation.Profile; -import org.springframework.core.io.ClassPathResource; -import org.springframework.retry.annotation.Backoff; -import org.springframework.retry.annotation.Retryable; -import org.springframework.stereotype.Service; - import com.apple.itunes.storekit.client.APIException; -import com.apple.itunes.storekit.client.AppStoreServerAPIClient; import com.apple.itunes.storekit.model.ConsumptionRequest; import com.apple.itunes.storekit.model.Data; -import com.apple.itunes.storekit.model.Environment; import com.apple.itunes.storekit.model.JWSTransactionDecodedPayload; import com.apple.itunes.storekit.model.ResponseBodyV2; import com.apple.itunes.storekit.model.ResponseBodyV2DecodedPayload; -import com.apple.itunes.storekit.model.TransactionInfoResponse; -import com.apple.itunes.storekit.verification.SignedDataVerifier; import com.apple.itunes.storekit.verification.VerificationException; - -import jakarta.annotation.PostConstruct; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; +import java.io.IOException; import umc.th.juinjang.api.apple.service.command.AppleTransactionVerifyCommand; -import umc.th.juinjang.api.pencil.service.PencilQueryService; import umc.th.juinjang.api.pencil.service.response.VerificationResult; -import umc.th.juinjang.common.code.status.ErrorStatus; -import umc.th.juinjang.common.exception.handler.AppleHandler; - -@Slf4j -@Service -@Profile("!local") -@RequiredArgsConstructor -public class AppleService { - - @Value("${apple.iap.bundle-id}") - private String bundleId; - - @Value("${apple.iap.key-id}") - private String keyId; - - @Value("${apple.iap.issuer-id}") - private String issuerId; - - @Value("${apple.iap.apple-id}") - private String appleIdStr; - - @Value("${apple.iap.environment}") - private String environmentString; // SANDBOX , PRODUCTION - - @Value("${apple.iap.certificate-names}") - private String certificateConfigs; - - @Value("${apple.iap.private-key-path}") - private String privateKeyPath; - - private SignedDataVerifier signedDataVerifier; - private AppStoreServerAPIClient appStoreServerAPIClient; - private PencilQueryService pencilQueryService; - - @PostConstruct - public void init() { - - log.info("Apple IAP 초기화 시작"); - log.info("Bundle ID: {}", bundleId); - log.info("Key ID: {}", keyId); - log.info("Issuer ID: {}", issuerId); - log.info("Environment: {}", environmentString); - log.info("Private Key Path: {}", privateKeyPath); - - Set rootCertificates = loadRootCertificates(); - - Environment environment = Environment.fromValue(environmentString); - Long appleId = Long.valueOf(appleIdStr); - - this.signedDataVerifier = new SignedDataVerifier( - rootCertificates, - bundleId, - appleId, - environment, - true - ); - - String signingKey = loadSigningKey(); - - this.appStoreServerAPIClient = new AppStoreServerAPIClient( - signingKey, - keyId, - issuerId, - bundleId, - environment - ); - - } - - @Retryable( - maxAttempts = 3, - backoff = @Backoff(delay = 1000), - retryFor = {APIException.class, IOException.class, VerificationException.class}) - public JWSTransactionDecodedPayload getTransactionInfo(String transactionId) throws - APIException, - IOException, - VerificationException { - log.info("Executing GetTransactionInfo for TRANSACTION_ID: {} - Thread: {}", - transactionId, Thread.currentThread().getName()); - - TransactionInfoResponse transactionInfo = appStoreServerAPIClient.getTransactionInfo(transactionId); - return signedDataVerifier.verifyAndDecodeTransaction(transactionInfo.getSignedTransactionInfo()); - } - - public VerificationResult verifyAppleTransaction(AppleTransactionVerifyCommand command) { - try { - JWSTransactionDecodedPayload payload = getTransactionInfo(command.getTransactionId()); - - if (!validateTransaction(payload, command)) { - return VerificationResult.ofVerificationError(); - } - - return VerificationResult.ofSuccess(payload); - - } catch (IOException | APIException e) { - log.warn("❌ Apple transaction verification error. transactionId: {}", command.getTransactionId(), e); - return VerificationResult.ofServerError(); - } catch (VerificationException e) { - log.warn("❌ Apple transaction verification error. transactionId: {}", command.getTransactionId(), e); - return VerificationResult.ofVerificationError(); - } - } - - public void sendConsumptionData(String transactionId, ConsumptionRequest request) { - try { - appStoreServerAPIClient.sendConsumptionData(transactionId, request); - } catch (IOException | APIException e) { - throw new AppleHandler(ErrorStatus.APPLE_VERIFICATION_ERROR); - } - - } - - public ResponseBodyV2DecodedPayload getNotificationPayload(ResponseBodyV2 responseBody) { - try { - return signedDataVerifier.verifyAndDecodeNotification( - responseBody.getSignedPayload()); - } catch (VerificationException e) { - throw new AppleHandler(ErrorStatus.APPLE_VERIFICATION_ERROR); - } - } - - public JWSTransactionDecodedPayload getSignedTransactionPayload( - Data data - ) { - try { - return signedDataVerifier.verifyAndDecodeTransaction( - data.getSignedTransactionInfo()); - } catch (VerificationException e) { - throw new AppleHandler(ErrorStatus.APPLE_VERIFICATION_ERROR); - } - } - - private boolean validateTransaction(JWSTransactionDecodedPayload decodedPayload, - AppleTransactionVerifyCommand command) { - // 트랜잭션 아이디가 정상적으로 일치하는 지 여부 - if (!decodedPayload.getTransactionId().equals(command.getTransactionId())) { - log.warn("트랜잭션 아이디 불일치. 애플 PAYLOAD : {}, REQUEST 요청 : {}", decodedPayload.getTransactionId(), - command.getTransactionId()); - return false; - } - - // 1. 환불/취소 여부 확인 - if (decodedPayload.getRevocationDate() != null || decodedPayload.getRevocationReason() != null) { - log.warn("트랜잭션이 취소되었습니다. 트랜잭션 ID: {}, 취소 이유: {}", - decodedPayload.getTransactionId(), decodedPayload.getRevocationReason()); - return false; - } - - // 2. 번들 ID가 앱의 번들 ID와 일치하는지 검증 - if (!bundleId.equals(decodedPayload.getBundleId())) { - log.warn("번들 ID 불일치. 예상: {}, 실제: {}", - bundleId, decodedPayload.getBundleId()); - return false; - } - - // 3. 상품 ID가 요청한 상품과 일치하는지 검증 - if (!command.getProductId().equals(decodedPayload.getProductId())) { - log.warn("상품 ID 불일치. 요청: {}, 응답: {}", - command.getProductId(), decodedPayload.getProductId()); - return false; - } - - // 4. 환경 확인 - 프로덕션에서는 프로덕션, 개발에서는 샌드박스인지 확인 - // boolean isProduction = !"Sandbox".equalsIgnoreCase(decodedPayload.getEnvironment()); - // if (isProduction) { - // log.warn("환경 불일치. 프로덕션 여부: {}, 프로덕션이어야 함: {}", - // isProduction, shouldBeProduction); - // return false; - // } - - // 5. 수량 검증 - if (decodedPayload.getQuantity() <= 0) { - log.warn("유효하지 않은 수량: {}", decodedPayload.getQuantity()); - return false; - } - - // 6. 앱 계정 토큰이 제공된 경우 일치하는지 확인 - if (command.getAppAccountToken() != null && decodedPayload.getAppAccountToken() != null && - !command.getAppAccountToken().equals(decodedPayload.getAppAccountToken())) { - log.warn("앱 계정 토큰 불일치. 요청: {}, 응답: {}", - command.getAppAccountToken(), decodedPayload.getAppAccountToken()); - return false; - } - - // 7. 모든 검증이 완료되었으므로 true 반환 - log.info("Apple IAP Purchase Validation Success. Transaction ID: {}", decodedPayload.getTransactionId()); - return true; - } - - private Set loadRootCertificates() { - try { - Set certificates = new HashSet<>(); - String[] certConfigs = certificateConfigs.split(","); - - for (String name : certConfigs) { - String certPath = "certs/" + name.trim(); - ClassPathResource resource = new ClassPathResource(certPath); - - if (resource.exists()) { - log.info("Loading certificate: {}", certPath); - certificates.add(resource.getInputStream()); - } else { - log.warn("Certificate not found: {}", certPath); - } - } - - if (certificates.isEmpty()) { - log.error("No certificates were loaded"); - throw new RuntimeException("Failed to load any certificates"); - } - return certificates; - } catch (Exception e) { - log.error("Error loading root certificates: {}", e.getMessage(), e); - throw new RuntimeException("Failed to load root certificates", e); - } - } +public interface AppleService { - private String loadSigningKey() { - try { - log.info("Loading signing key from: {}", privateKeyPath); + JWSTransactionDecodedPayload getTransactionInfo(String transactionId) throws + APIException, IOException, VerificationException; - ClassPathResource resource = new ClassPathResource(privateKeyPath); - String privateKeyContent; + VerificationResult verifyAppleTransaction(AppleTransactionVerifyCommand command); - try (InputStream inputStream = resource.getInputStream()) { - privateKeyContent = new String(inputStream.readAllBytes()); - } + void sendConsumptionData(String transactionId, ConsumptionRequest request); - log.info("Signing key loaded successfully"); - return privateKeyContent; + ResponseBodyV2DecodedPayload getNotificationPayload(ResponseBodyV2 responseBody); - } catch (Exception e) { - log.error("Failed to load signing key: {}", e.getMessage(), e); - throw new RuntimeException("Failed to load signing key", e); - } - } + JWSTransactionDecodedPayload getSignedTransactionPayload(Data data); } diff --git a/src/main/java/umc/th/juinjang/api/apple/service/AppleServiceImpl.java b/src/main/java/umc/th/juinjang/api/apple/service/AppleServiceImpl.java new file mode 100644 index 00000000..e6e2251c --- /dev/null +++ b/src/main/java/umc/th/juinjang/api/apple/service/AppleServiceImpl.java @@ -0,0 +1,271 @@ +package umc.th.juinjang.api.apple.service; + +import com.apple.itunes.storekit.client.APIException; +import com.apple.itunes.storekit.client.AppStoreServerAPIClient; +import com.apple.itunes.storekit.model.ConsumptionRequest; +import com.apple.itunes.storekit.model.Data; +import com.apple.itunes.storekit.model.Environment; +import com.apple.itunes.storekit.model.JWSTransactionDecodedPayload; +import com.apple.itunes.storekit.model.ResponseBodyV2; +import com.apple.itunes.storekit.model.ResponseBodyV2DecodedPayload; +import com.apple.itunes.storekit.model.TransactionInfoResponse; +import com.apple.itunes.storekit.verification.SignedDataVerifier; +import com.apple.itunes.storekit.verification.VerificationException; +import jakarta.annotation.PostConstruct; +import java.io.IOException; +import java.io.InputStream; +import java.util.HashSet; +import java.util.Set; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.core.io.ClassPathResource; +import org.springframework.retry.annotation.Backoff; +import org.springframework.retry.annotation.Retryable; +import org.springframework.stereotype.Service; +import umc.th.juinjang.api.apple.service.command.AppleTransactionVerifyCommand; +import umc.th.juinjang.api.pencil.service.PencilQueryService; +import umc.th.juinjang.api.pencil.service.response.VerificationResult; +import umc.th.juinjang.common.code.status.ErrorStatus; +import umc.th.juinjang.common.exception.handler.AppleHandler; + +@Slf4j +@Service +@ConditionalOnProperty( + name = "apple.iap.enabled", + havingValue = "true", + matchIfMissing = false +) +@RequiredArgsConstructor +public class AppleServiceImpl implements AppleService { + + @Value("${apple.iap.bundle-id}") + private String bundleId; + + @Value("${apple.iap.key-id}") + private String keyId; + + @Value("${apple.iap.issuer-id}") + private String issuerId; + + @Value("${apple.iap.apple-id}") + private String appleIdStr; + + @Value("${apple.iap.environment}") + private String environmentString; // SANDBOX , PRODUCTION + + @Value("${apple.iap.certificate-names}") + private String certificateConfigs; + + @Value("${apple.iap.private-key-path}") + private String privateKeyPath; + + private SignedDataVerifier signedDataVerifier; + private AppStoreServerAPIClient appStoreServerAPIClient; + private PencilQueryService pencilQueryService; + + @PostConstruct + public void init() { + + log.info("Apple IAP 초기화 시작"); + log.info("Bundle ID: {}", bundleId); + log.info("Key ID: {}", keyId); + log.info("Issuer ID: {}", issuerId); + log.info("Environment: {}", environmentString); + log.info("Private Key Path: {}", privateKeyPath); + + Set rootCertificates = loadRootCertificates(); + + Environment environment = Environment.fromValue(environmentString); + Long appleId = Long.valueOf(appleIdStr); + + this.signedDataVerifier = new SignedDataVerifier( + rootCertificates, + bundleId, + appleId, + environment, + true + ); + + String signingKey = loadSigningKey(); + + this.appStoreServerAPIClient = new AppStoreServerAPIClient( + signingKey, + keyId, + issuerId, + bundleId, + environment + ); + } + + @Retryable( + maxAttempts = 3, + backoff = @Backoff(delay = 1000), + retryFor = {APIException.class, IOException.class, VerificationException.class}) + public JWSTransactionDecodedPayload getTransactionInfo(String transactionId) throws + APIException, + IOException, + VerificationException { + log.info("Executing GetTransactionInfo for TRANSACTION_ID: {} - Thread: {}", + transactionId, Thread.currentThread().getName()); + + TransactionInfoResponse transactionInfo = appStoreServerAPIClient.getTransactionInfo(transactionId); + return signedDataVerifier.verifyAndDecodeTransaction(transactionInfo.getSignedTransactionInfo()); + } + + public VerificationResult verifyAppleTransaction(AppleTransactionVerifyCommand command) { + try { + JWSTransactionDecodedPayload payload = getTransactionInfo(command.getTransactionId()); + + if (!validateTransaction(payload, command)) { + return VerificationResult.ofVerificationError(); + } + + return VerificationResult.ofSuccess(payload); + + } catch (IOException | APIException e) { + log.warn("❌ Apple transaction verification error. transactionId: {}", command.getTransactionId(), e); + return VerificationResult.ofServerError(); + } catch (VerificationException e) { + log.warn("❌ Apple transaction verification error. transactionId: {}", command.getTransactionId(), e); + return VerificationResult.ofVerificationError(); + } + } + + public void sendConsumptionData(String transactionId, ConsumptionRequest request) { + try { + appStoreServerAPIClient.sendConsumptionData(transactionId, request); + } catch (IOException | APIException e) { + throw new AppleHandler(ErrorStatus.APPLE_VERIFICATION_ERROR); + } + + } + + public ResponseBodyV2DecodedPayload getNotificationPayload(ResponseBodyV2 responseBody) { + try { + return signedDataVerifier.verifyAndDecodeNotification( + responseBody.getSignedPayload()); + } catch (VerificationException e) { + throw new AppleHandler(ErrorStatus.APPLE_VERIFICATION_ERROR); + } + } + + public JWSTransactionDecodedPayload getSignedTransactionPayload( + Data data + ) { + try { + return signedDataVerifier.verifyAndDecodeTransaction( + data.getSignedTransactionInfo()); + } catch (VerificationException e) { + throw new AppleHandler(ErrorStatus.APPLE_VERIFICATION_ERROR); + } + } + + private boolean validateTransaction(JWSTransactionDecodedPayload decodedPayload, + AppleTransactionVerifyCommand command) { + // 트랜잭션 아이디가 정상적으로 일치하는 지 여부 + if (!decodedPayload.getTransactionId().equals(command.getTransactionId())) { + log.warn("트랜잭션 아이디 불일치. 애플 PAYLOAD : {}, REQUEST 요청 : {}", decodedPayload.getTransactionId(), + command.getTransactionId()); + return false; + } + + // 1. 환불/취소 여부 확인 + if (decodedPayload.getRevocationDate() != null || decodedPayload.getRevocationReason() != null) { + log.warn("트랜잭션이 취소되었습니다. 트랜잭션 ID: {}, 취소 이유: {}", + decodedPayload.getTransactionId(), decodedPayload.getRevocationReason()); + return false; + } + + // 2. 번들 ID가 앱의 번들 ID와 일치하는지 검증 + if (!bundleId.equals(decodedPayload.getBundleId())) { + log.warn("번들 ID 불일치. 예상: {}, 실제: {}", + bundleId, decodedPayload.getBundleId()); + return false; + } + + // 3. 상품 ID가 요청한 상품과 일치하는지 검증 + if (!command.getProductId().equals(decodedPayload.getProductId())) { + log.warn("상품 ID 불일치. 요청: {}, 응답: {}", + command.getProductId(), decodedPayload.getProductId()); + return false; + } + + // 4. 환경 확인 - 프로덕션에서는 프로덕션, 개발에서는 샌드박스인지 확인 + // boolean isProduction = !"Sandbox".equalsIgnoreCase(decodedPayload.getEnvironment()); + // if (isProduction) { + // log.warn("환경 불일치. 프로덕션 여부: {}, 프로덕션이어야 함: {}", + // isProduction, shouldBeProduction); + // return false; + // } + + // 5. 수량 검증 + if (decodedPayload.getQuantity() <= 0) { + log.warn("유효하지 않은 수량: {}", decodedPayload.getQuantity()); + return false; + } + + // 6. 앱 계정 토큰이 제공된 경우 일치하는지 확인 + if (command.getAppAccountToken() != null && decodedPayload.getAppAccountToken() != null && + !command.getAppAccountToken().equals(decodedPayload.getAppAccountToken())) { + log.warn("앱 계정 토큰 불일치. 요청: {}, 응답: {}", + command.getAppAccountToken(), decodedPayload.getAppAccountToken()); + return false; + } + + // 7. 모든 검증이 완료되었으므로 true 반환 + log.info("Apple IAP Purchase Validation Success. Transaction ID: {}", decodedPayload.getTransactionId()); + return true; + } + + private Set loadRootCertificates() { + try { + Set certificates = new HashSet<>(); + String[] certConfigs = certificateConfigs.split(","); + + for (String name : certConfigs) { + String certPath = "certs/" + name.trim(); + ClassPathResource resource = new ClassPathResource(certPath); + + if (resource.exists()) { + log.info("Loading certificate: {}", certPath); + certificates.add(resource.getInputStream()); + } else { + log.warn("Certificate not found: {}", certPath); + } + } + + if (certificates.isEmpty()) { + log.error("No certificates were loaded"); + throw new RuntimeException("Failed to load any certificates"); + } + + return certificates; + } catch (Exception e) { + log.error("Error loading root certificates: {}", e.getMessage(), e); + throw new RuntimeException("Failed to load root certificates", e); + } + } + + private String loadSigningKey() { + try { + log.info("Loading signing key from: {}", privateKeyPath); + + ClassPathResource resource = new ClassPathResource(privateKeyPath); + String privateKeyContent; + + try (InputStream inputStream = resource.getInputStream()) { + privateKeyContent = new String(inputStream.readAllBytes()); + } + + log.info("Signing key loaded successfully"); + return privateKeyContent; + + } catch (Exception e) { + log.error("Failed to load signing key: {}", e.getMessage(), e); + throw new RuntimeException("Failed to load signing key", e); + } + } + +} diff --git a/src/main/java/umc/th/juinjang/api/apple/service/AppleServiceStub.java b/src/main/java/umc/th/juinjang/api/apple/service/AppleServiceStub.java new file mode 100644 index 00000000..361f48a8 --- /dev/null +++ b/src/main/java/umc/th/juinjang/api/apple/service/AppleServiceStub.java @@ -0,0 +1,55 @@ +package umc.th.juinjang.api.apple.service; + +import com.apple.itunes.storekit.client.APIException; +import com.apple.itunes.storekit.model.ConsumptionRequest; +import com.apple.itunes.storekit.model.Data; +import com.apple.itunes.storekit.model.JWSTransactionDecodedPayload; +import com.apple.itunes.storekit.model.ResponseBodyV2; +import com.apple.itunes.storekit.model.ResponseBodyV2DecodedPayload; +import com.apple.itunes.storekit.verification.VerificationException; +import java.io.IOException; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Service; +import umc.th.juinjang.api.apple.service.command.AppleTransactionVerifyCommand; +import umc.th.juinjang.api.pencil.service.response.VerificationResult; + +@Slf4j +@Service +@ConditionalOnProperty( + name = "apple.iap.enabled", + havingValue = "false", + matchIfMissing = true +) +public class AppleServiceStub implements AppleService { + + @Override + public JWSTransactionDecodedPayload getTransactionInfo(String transactionId) throws + APIException, IOException, VerificationException { + log.warn("Apple IAP가 비활성화되어 있습니다. TransactionId: {}", transactionId); + throw new UnsupportedOperationException("Apple IAP is disabled"); + } + + @Override + public VerificationResult verifyAppleTransaction(AppleTransactionVerifyCommand command) { + log.warn("Apple IAP가 비활성화되어 있습니다. TransactionId: {}", command.getTransactionId()); + return VerificationResult.ofServerError(); + } + + @Override + public void sendConsumptionData(String transactionId, ConsumptionRequest request) { + log.warn("Apple IAP가 비활성화되어 있습니다. sendConsumptionData 호출 무시"); + } + + @Override + public ResponseBodyV2DecodedPayload getNotificationPayload(ResponseBodyV2 responseBody) { + log.warn("Apple IAP가 비활성화되어 있습니다. getNotificationPayload 호출"); + throw new UnsupportedOperationException("Apple IAP is disabled"); + } + + @Override + public JWSTransactionDecodedPayload getSignedTransactionPayload(Data data) { + log.warn("Apple IAP가 비활성화되어 있습니다. getSignedTransactionPayload 호출"); + throw new UnsupportedOperationException("Apple IAP is disabled"); + } +} diff --git a/src/main/java/umc/th/juinjang/api/limjang/controller/NoteControllerV2.java b/src/main/java/umc/th/juinjang/api/limjang/controller/NoteControllerV2.java index 8120692a..6d13b69a 100644 --- a/src/main/java/umc/th/juinjang/api/limjang/controller/NoteControllerV2.java +++ b/src/main/java/umc/th/juinjang/api/limjang/controller/NoteControllerV2.java @@ -15,6 +15,7 @@ import lombok.RequiredArgsConstructor; import umc.th.juinjang.api.dto.ApiResponse; import umc.th.juinjang.api.limjang.controller.parameter.LimjangSortOptions; +import umc.th.juinjang.api.limjang.controller.request.NoteInitRequest; import umc.th.juinjang.api.limjang.controller.request.NotePatchRequest; import umc.th.juinjang.api.limjang.controller.request.NotePostRequest; import umc.th.juinjang.api.limjang.service.NoteCommandServiceV2; @@ -42,6 +43,13 @@ public ApiResponse createNote(@RequestBody @Valid NotePostRequ return ApiResponse.of(SuccessStatus._CREATED, noteCommandService.createNote(request, member)); } + @Operation(summary = "임장 생성 API INIT V2 - 간편한 임장 생성") + @PostMapping("/notes/init") + public ApiResponse initNote(@RequestBody @Valid NoteInitRequest request, + @AuthenticationPrincipal Member member) { + return ApiResponse.of(SuccessStatus._CREATED, noteCommandService.initNote(request, member)); + } + @Operation(summary = "마이 노트 조회 API V2") @GetMapping("/notes") public ApiResponse findUsersNotes( @@ -56,7 +64,16 @@ public ApiResponse findUsersNotes( public ApiResponse updateNote(@PathVariable(name = "noteId") Long noteId, @RequestBody @Valid NotePatchRequest request, @AuthenticationPrincipal Member member) { - noteCommandService.updateNote(noteId, request); + noteCommandService.updateNoteV2(noteId, request); + return ApiResponse.onSuccess(null); + } + + @Operation(summary = "임장 수정 API V2 - UI/UX 리팩토링") + @PatchMapping("/notes/init/{noteId}") + public ApiResponse updateNoteV2(@PathVariable(name = "noteId") Long noteId, + @RequestBody @Valid NotePatchRequest request, + @AuthenticationPrincipal Member member) { + noteCommandService.updateNoteV2(noteId, request); return ApiResponse.onSuccess(null); } diff --git a/src/main/java/umc/th/juinjang/api/limjang/controller/request/LimjangsDeleteRequest.java b/src/main/java/umc/th/juinjang/api/limjang/controller/request/LimjangsDeleteRequest.java index 52962440..01e62457 100644 --- a/src/main/java/umc/th/juinjang/api/limjang/controller/request/LimjangsDeleteRequest.java +++ b/src/main/java/umc/th/juinjang/api/limjang/controller/request/LimjangsDeleteRequest.java @@ -1,9 +1,13 @@ package umc.th.juinjang.api.limjang.controller.request; -import jakarta.validation.constraints.NotEmpty; import java.util.List; +import jakarta.validation.constraints.NotEmpty; + public record LimjangsDeleteRequest( - @NotEmpty List limjangIdList + @NotEmpty List limjangIdList ) { + public static LimjangsDeleteRequest of(List limjangIds) { + return new LimjangsDeleteRequest(limjangIds); + } } \ No newline at end of file diff --git a/src/main/java/umc/th/juinjang/api/limjang/controller/request/NoteInitRequest.java b/src/main/java/umc/th/juinjang/api/limjang/controller/request/NoteInitRequest.java new file mode 100644 index 00000000..de7db96d --- /dev/null +++ b/src/main/java/umc/th/juinjang/api/limjang/controller/request/NoteInitRequest.java @@ -0,0 +1,32 @@ +package umc.th.juinjang.api.limjang.controller.request; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Pattern; +import umc.th.juinjang.domain.limjang.model.Limjang; +import umc.th.juinjang.domain.limjang.model.LimjangPrice; +import umc.th.juinjang.domain.limjang.model.LimjangPriceType; +import umc.th.juinjang.domain.limjang.model.LimjangPropertyType; +import umc.th.juinjang.domain.limjang.model.LimjangPurpose; +import umc.th.juinjang.domain.limjang.repository.NotePriceFactory; +import umc.th.juinjang.domain.member.model.Member; + +public record NoteInitRequest( + @NotNull + LimjangPurpose purposeType, + @NotNull + LimjangPropertyType propertyType, + @NotNull + LimjangPriceType priceType, + @NotBlank + @Pattern(regexp = "^[0-9]+$", message = "가격은 숫자만 입력해야 합니다.") + String price, + @Pattern(regexp = "^[0-9]+$", message = "가격은 숫자만 입력해야 합니다.") + String monthlyRent +) { + public Limjang toEntity(Member member) { + LimjangPrice limjangPrice = NotePriceFactory.create(purposeType, priceType, price, monthlyRent); + + return Limjang.initNote(member, limjangPrice, purposeType, propertyType, priceType); + } +} diff --git a/src/main/java/umc/th/juinjang/api/limjang/service/NoteCommandServiceV2.java b/src/main/java/umc/th/juinjang/api/limjang/service/NoteCommandServiceV2.java index 20fda0ca..78b3137d 100644 --- a/src/main/java/umc/th/juinjang/api/limjang/service/NoteCommandServiceV2.java +++ b/src/main/java/umc/th/juinjang/api/limjang/service/NoteCommandServiceV2.java @@ -3,10 +3,12 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; +import umc.th.juinjang.api.address.service.AddressUpdater; +import umc.th.juinjang.api.limjang.controller.request.NoteInitRequest; import umc.th.juinjang.api.limjang.controller.request.NotePatchRequest; import umc.th.juinjang.api.limjang.controller.request.NotePostRequest; -import umc.th.juinjang.api.address.service.AddressUpdater; import umc.th.juinjang.api.limjang.service.response.NotePostResponse; import umc.th.juinjang.common.code.status.ErrorStatus; import umc.th.juinjang.common.exception.handler.LimjangHandler; @@ -52,6 +54,26 @@ public void updateNote(Long noteId, NotePatchRequest request) { note.updateNote(request.nickname(), request.priceType(), request.floor(), request.pyong()); } + @Transactional + public void updateNoteV2(Long noteId, NotePatchRequest request) { + Limjang note = noteFinder.getNoteByIdWhereDeletedIsFalse(noteId); + + validatePriceType(note.getPurpose(), request.priceType()); + + LimjangPrice newPrice = request.toUpdatedPrice(note.getPurpose()); + Address newAddress = request.toUpdatedAddress(); + + if (note.getAddressEntity() != null) { + note.getAddressEntity().update(newAddress); + } else { + addressUpdater.save(newAddress); + note.setAddressEntity(newAddress); + } + + note.getLimjangPrice().updateLimjangPrice(newPrice); + note.updateNote(request.nickname(), request.priceType(), request.floor(), request.pyong()); + } + private void validatePriceType(LimjangPurpose purposeType, LimjangPriceType priceType) { if ( (purposeType == LimjangPurpose.RESIDENTIAL_PURPOSE && priceType == LimjangPriceType.MARKET_PRICE) || @@ -60,4 +82,13 @@ private void validatePriceType(LimjangPurpose purposeType, LimjangPriceType pric throw new LimjangHandler(ErrorStatus.LIMJANG_POST_TYPE_ERROR); } } + + public NotePostResponse initNote(@Valid NoteInitRequest request, Member member) { + Limjang note = request.toEntity(member); + validatePriceType(request.purposeType(), request.priceType()); + + Limjang savedNote = noteUpdater.save(note); + + return NotePostResponse.of(savedNote.getLimjangId()); + } } diff --git a/src/main/java/umc/th/juinjang/api/limjang/service/response/UserNoteGetResponse.java b/src/main/java/umc/th/juinjang/api/limjang/service/response/UserNoteGetResponse.java index 2496cb60..e14fd7ab 100644 --- a/src/main/java/umc/th/juinjang/api/limjang/service/response/UserNoteGetResponse.java +++ b/src/main/java/umc/th/juinjang/api/limjang/service/response/UserNoteGetResponse.java @@ -38,18 +38,18 @@ public static UserNoteGetResponse of(boolean isShared, Limjang note, Address add note.getPriceType(), note.getNickname(), note.getImageList().stream().map(Image::getImageUrl).limit(3).toList(), - address.getRoadAddress(), - address.getAddressDetail(), + address != null ? address.getRoadAddress() : null, + address != null ? address.getAddressDetail() : null, note.getLimjangPrice().getPrice(note.getPriceType(), note.getPurpose()), note.getPriceType() == LimjangPriceType.MONTHLY_RENT ? note.getLimjangPrice().getMonthlyRent() : null, note.getUpdatedAt().format(DateTimeFormatter.ofPattern("yy.MM.dd")), note.getFloor(), note.getPyong(), - address.getBcode(), - address.getSido(), - address.getSigungo(), - address.getBname1(), - address.getBname2() + address != null ? address.getBcode() : null, + address != null ? address.getSido() : null, + address != null ? address.getSigungo() : null, + address != null ? address.getBname1() : null, + address != null ? address.getBname2() : null ); } } diff --git a/src/main/java/umc/th/juinjang/api/limjang/service/response/UserNotesGetResponse.java b/src/main/java/umc/th/juinjang/api/limjang/service/response/UserNotesGetResponse.java index 51038f39..27288eeb 100644 --- a/src/main/java/umc/th/juinjang/api/limjang/service/response/UserNotesGetResponse.java +++ b/src/main/java/umc/th/juinjang/api/limjang/service/response/UserNotesGetResponse.java @@ -12,7 +12,7 @@ public record UserNotesGetResponse( List notes ) { - record UserNoteResponse( + public record UserNoteResponse( long noteId, LimjangPurpose purposeType, LimjangPropertyType propertyType, @@ -30,18 +30,22 @@ record UserNoteResponse( ) { static UserNoteResponse of(Limjang limjang, boolean isScraped) { return new UserNoteResponse( - limjang.getLimjangId(), limjang.getPurpose(), limjang.getPropertyType(), limjang.getPriceType(), + limjang.getLimjangId(), + limjang.getPurpose(), + limjang.getPropertyType(), + limjang.getPriceType(), limjang.getNickname(), limjang.getImageList().stream().map(Image::getImageUrl).limit(3).toList(), isScraped, limjang.getReport() == null ? null : limjang.getReport().getTotalRate().toString(), limjang.getLimjangPrice().getPrice(limjang.getPriceType(), limjang.getPurpose()), - limjang.getPriceType() == LimjangPriceType.MONTHLY_RENT ? limjang.getLimjangPrice().getMonthlyRent() : - null, + limjang.getPriceType() == LimjangPriceType.MONTHLY_RENT ? + limjang.getLimjangPrice().getMonthlyRent() : null, limjang.getPyong(), limjang.getFloor(), - limjang.getAddressEntity().getRoadAddress(), - limjang.getAddressEntity().getShortAddress()); + limjang.getAddressEntity() != null ? limjang.getAddressEntity().getRoadAddress() : null, + limjang.getAddressEntity() != null ? limjang.getAddressEntity().getShortAddress() : null + ); } } diff --git a/src/main/java/umc/th/juinjang/api/pencil/service/PencilCommandService.java b/src/main/java/umc/th/juinjang/api/pencil/service/PencilCommandService.java index d45d7b23..e4b1ea2c 100644 --- a/src/main/java/umc/th/juinjang/api/pencil/service/PencilCommandService.java +++ b/src/main/java/umc/th/juinjang/api/pencil/service/PencilCommandService.java @@ -1,14 +1,12 @@ package umc.th.juinjang.api.pencil.service; +import jakarta.persistence.EntityNotFoundException; import java.time.LocalDateTime; import java.util.Optional; - -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -import jakarta.persistence.EntityNotFoundException; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; import umc.th.juinjang.api.apple.service.AppleService; import umc.th.juinjang.api.apple.service.command.AppleTransactionVerifyCommand; import umc.th.juinjang.api.pencil.controller.request.AppleIAPPurchaseRequest; @@ -27,163 +25,162 @@ @RequiredArgsConstructor public class PencilCommandService { - private final AppleService appleService; - - private final PurchasedPencilUpdater purchasedPencilUpdater; - private final PurchasedPencilFinder purchasedPencilFinder; - private final AcquiredPencilFinder acquiredPencilFinder; - private final PencilAccountFinder pencilAccountFinder; - private final PaymentEventPublisher paymentEventPublisher; - - @Transactional - public Boolean markAcquiredPencilAsRead(Long acquiredPencilId) { - AcquiredPencil acquiredPencil = acquiredPencilFinder.findById(acquiredPencilId); - - if (acquiredPencil == null) { - throw new EntityNotFoundException("AcquiredPencil not found with id: " + acquiredPencilId); - } - - acquiredPencil.updateIsReadAsTrue(); - return true; - } - - @Transactional - public AppleIAPPurchaseResponse processAppleIAPPurchase(AppleIAPPurchaseRequest request, Member member, - LocalDateTime now) { - String transactionId = request.getTransactionId(); - Long purchaseQuantity = request.getPencilQuantity(); - Optional existing = purchasedPencilFinder.findByTransactionIdAndMember(transactionId, member); - - if (existing.isEmpty()) { - // 기존 트랜잭션이 없는 경우 - log.info("기존 트랜잭션이 없습니다. {}", transactionId); - return validateAndCommitApplePurchase(request, member, now); - } - - PurchasedPencil pencil = existing.get(); - TransactionStatus status = pencil.getTransactionStatus(); - PencilAccount buyer = pencilAccountFinder.findByMember(member); - - if (status == TransactionStatus.SUCCESS) { - // 트랜잭션이 정상적으로 성공된 기록이 있는 경우 - return AppleIAPPurchaseResponse.ofSuccess(transactionId, purchaseQuantity, buyer.getTotalBalance()); - } - - PurchasedPencil newPencil = retryPurchasedPencil(request, pencil, member); // 실패 재시도 처리 - return AppleIAPPurchaseResponse.of(transactionId, newPencil.getTransactionStatus(), purchaseQuantity, - buyer.getTotalBalance()); - } - - @Transactional - public AppleIAPPurchaseResponse validateAndCommitApplePurchase(AppleIAPPurchaseRequest request, Member member, - LocalDateTime now) { - String transactionId = request.getTransactionId(); - - VerificationResult verificationResult = appleService.verifyAppleTransaction( - AppleTransactionVerifyCommand.fromRequest(request)); - - if (VerificationResult.isSuccess(verificationResult)) { - // 성공 시, DB에 저장 - handleSuccessfulApplePurchase(request, member, now); - - paymentEventPublisher.publishPaymentEvent(member, request.getPrice(), request.getPencilQuantity(), - TransactionStatus.SUCCESS); - PencilAccount buyer = pencilAccountFinder.findByMember(member); - return AppleIAPPurchaseResponse.ofSuccess(transactionId, request.getPencilQuantity(), - buyer.getTotalBalance()); - } else { - // 실패 시, DB에 저장 - handleFailureApplePurchase(request, member, now); - - paymentEventPublisher.publishPaymentEvent(member, request.getPrice(), request.getPencilQuantity(), - TransactionStatus.VALIDATION_FAILED); - return AppleIAPPurchaseResponse.ofValidationFailure(transactionId); - } - } - - @Transactional - public void handleSuccessfulApplePurchase(AppleIAPPurchaseRequest request, Member member, LocalDateTime now) { - String transactionId = request.getTransactionId(); - Long pencilAmount = request.getPencilQuantity(); - - String title = createTitle(pencilAmount); - PencilAccount buyer = pencilAccountFinder.findByMemberWithLock(member); - buyer.increasePurchasedBalance(pencilAmount); - - purchasedPencilUpdater.save( - PurchasedPencil.successOf(member, title, pencilAmount, buyer.getTotalBalance(), request.getPrice(), - request.getPlayTime(), transactionId, request.getAppAccountToken(), now)); - - } - - @Transactional - public void handleFailureApplePurchase(AppleIAPPurchaseRequest request, Member member, LocalDateTime now) { - String transactionId = request.getTransactionId(); - Long pencilAmount = request.getPencilQuantity(); - - String title = createTitle(pencilAmount); - purchasedPencilUpdater.save( - PurchasedPencil.failedDueToValidation(member, title, pencilAmount, request.getPrice(), - request.getPlayTime(), transactionId, request.getAppAccountToken(), now)); - } - - @Transactional - public PurchasedPencil retryPurchasedPencil(AppleIAPPurchaseRequest request, PurchasedPencil pencil, - Member member) { - if (pencil.getRetryCount() >= 3) { // 재시도 횟수가 3회 이상일 경우 실패로 처리 - return pencil; - } - - VerificationResult verificationResult = appleService.verifyAppleTransaction( - AppleTransactionVerifyCommand.fromRequest(request) - ); - - if (VerificationResult.isSuccess(verificationResult)) { - pencil.markAsSuccess(); - pencil.updateRetryCount(pencil.getRetryCount() + 1); - - pencilAccountFinder.findByMemberWithLock(member) - .increasePurchasedBalance(pencil.getPurchaseQuantity()); - } - - return pencil; - } - - private String createTitle(Long pencilAmount) { - return String.format("연필 %d개 구매", pencilAmount); - } - - @Transactional - public void handleRefundPurchase(String transactionId) { - PurchasedPencil pencil = purchasedPencilFinder.findByTransactionId(transactionId) - .orElseThrow( - () -> new EntityNotFoundException("PurchasedPencil not found with transactionId: " + transactionId)); - - log.info("Refund processed for transactionId: {}", transactionId); - - pencil.markAsRefund(); - - PencilAccount buyerAccount = pencilAccountFinder.findByMemberWithLock(pencil.getMember()); - executeRefund(buyerAccount, pencil.getPurchaseQuantity(), pencil.getPrice()); - - paymentEventPublisher.publishPaymentEvent(pencil.getMember(), pencil.getPrice(), pencil.getPurchaseQuantity(), - TransactionStatus.REFUNDED); - } - - public void executeRefund(PencilAccount buyerAccount, long pencilQuantity, long price) { - long purchasedToUse = Math.min(buyerAccount.getPurchasedBalance(), pencilQuantity); - buyerAccount.decreasePurchasedBalance(purchasedToUse); - - long remaining = pencilQuantity - purchasedToUse; - long acquiredToUse = Math.min(buyerAccount.getAcquiredBalance(), remaining); - buyerAccount.decreaseAcquiredBalance(acquiredToUse); - - buyerAccount.increaseTotalRefundAmount(price); - // 남은 수량이 0이 아닐 경우 로그 기록 - if (remaining - acquiredToUse > 0) { - log.warn("Not enough balance to fully refund {} pencils. Refunded only {}.", pencilQuantity, - (purchasedToUse + acquiredToUse)); - } - } + private final AppleService appleService; + private final PurchasedPencilUpdater purchasedPencilUpdater; + private final PurchasedPencilFinder purchasedPencilFinder; + private final AcquiredPencilFinder acquiredPencilFinder; + private final PencilAccountFinder pencilAccountFinder; + private final PaymentEventPublisher paymentEventPublisher; + + @Transactional + public Boolean markAcquiredPencilAsRead(Long acquiredPencilId) { + AcquiredPencil acquiredPencil = acquiredPencilFinder.findById(acquiredPencilId); + + if (acquiredPencil == null) { + throw new EntityNotFoundException("AcquiredPencil not found with id: " + acquiredPencilId); + } + + acquiredPencil.updateIsReadAsTrue(); + return true; + } + + @Transactional + public AppleIAPPurchaseResponse processAppleIAPPurchase(AppleIAPPurchaseRequest request, Member member, + LocalDateTime now) { + String transactionId = request.getTransactionId(); + Long purchaseQuantity = request.getPencilQuantity(); + Optional existing = purchasedPencilFinder.findByTransactionIdAndMember(transactionId, member); + + if (existing.isEmpty()) { + // 기존 트랜잭션이 없는 경우 + log.info("기존 트랜잭션이 없습니다. {}", transactionId); + return validateAndCommitApplePurchase(request, member, now); + } + + PurchasedPencil pencil = existing.get(); + TransactionStatus status = pencil.getTransactionStatus(); + PencilAccount buyer = pencilAccountFinder.findByMember(member); + + if (status == TransactionStatus.SUCCESS) { + // 트랜잭션이 정상적으로 성공된 기록이 있는 경우 + return AppleIAPPurchaseResponse.ofSuccess(transactionId, purchaseQuantity, buyer.getTotalBalance()); + } + + PurchasedPencil newPencil = retryPurchasedPencil(request, pencil, member); // 실패 재시도 처리 + return AppleIAPPurchaseResponse.of(transactionId, newPencil.getTransactionStatus(), purchaseQuantity, + buyer.getTotalBalance()); + } + + @Transactional + public AppleIAPPurchaseResponse validateAndCommitApplePurchase(AppleIAPPurchaseRequest request, Member member, + LocalDateTime now) { + String transactionId = request.getTransactionId(); + + VerificationResult verificationResult = appleService.verifyAppleTransaction( + AppleTransactionVerifyCommand.fromRequest(request)); + + if (VerificationResult.isSuccess(verificationResult)) { + // 성공 시, DB에 저장 + handleSuccessfulApplePurchase(request, member, now); + + paymentEventPublisher.publishPaymentEvent(member, request.getPrice(), request.getPencilQuantity(), + TransactionStatus.SUCCESS); + PencilAccount buyer = pencilAccountFinder.findByMember(member); + return AppleIAPPurchaseResponse.ofSuccess(transactionId, request.getPencilQuantity(), + buyer.getTotalBalance()); + } else { + // 실패 시, DB에 저장 + handleFailureApplePurchase(request, member, now); + + paymentEventPublisher.publishPaymentEvent(member, request.getPrice(), request.getPencilQuantity(), + TransactionStatus.VALIDATION_FAILED); + return AppleIAPPurchaseResponse.ofValidationFailure(transactionId); + } + } + + @Transactional + public void handleSuccessfulApplePurchase(AppleIAPPurchaseRequest request, Member member, LocalDateTime now) { + String transactionId = request.getTransactionId(); + Long pencilAmount = request.getPencilQuantity(); + + String title = createTitle(pencilAmount); + PencilAccount buyer = pencilAccountFinder.findByMemberWithLock(member); + buyer.increasePurchasedBalance(pencilAmount); + + purchasedPencilUpdater.save( + PurchasedPencil.successOf(member, title, pencilAmount, buyer.getTotalBalance(), request.getPrice(), + request.getPlayTime(), transactionId, request.getAppAccountToken(), now)); + + } + + @Transactional + public void handleFailureApplePurchase(AppleIAPPurchaseRequest request, Member member, LocalDateTime now) { + String transactionId = request.getTransactionId(); + Long pencilAmount = request.getPencilQuantity(); + + String title = createTitle(pencilAmount); + purchasedPencilUpdater.save( + PurchasedPencil.failedDueToValidation(member, title, pencilAmount, request.getPrice(), + request.getPlayTime(), transactionId, request.getAppAccountToken(), now)); + } + + @Transactional + public PurchasedPencil retryPurchasedPencil(AppleIAPPurchaseRequest request, PurchasedPencil pencil, + Member member) { + if (pencil.getRetryCount() >= 3) { // 재시도 횟수가 3회 이상일 경우 실패로 처리 + return pencil; + } + + VerificationResult verificationResult = appleService.verifyAppleTransaction( + AppleTransactionVerifyCommand.fromRequest(request) + ); + + if (VerificationResult.isSuccess(verificationResult)) { + pencil.markAsSuccess(); + pencil.updateRetryCount(pencil.getRetryCount() + 1); + + pencilAccountFinder.findByMemberWithLock(member) + .increasePurchasedBalance(pencil.getPurchaseQuantity()); + } + + return pencil; + } + + private String createTitle(Long pencilAmount) { + return String.format("연필 %d개 구매", pencilAmount); + } + + @Transactional + public void handleRefundPurchase(String transactionId) { + PurchasedPencil pencil = purchasedPencilFinder.findByTransactionId(transactionId) + .orElseThrow( + () -> new EntityNotFoundException("PurchasedPencil not found with transactionId: " + transactionId)); + + log.info("Refund processed for transactionId: {}", transactionId); + + pencil.markAsRefund(); + + PencilAccount buyerAccount = pencilAccountFinder.findByMemberWithLock(pencil.getMember()); + executeRefund(buyerAccount, pencil.getPurchaseQuantity(), pencil.getPrice()); + + paymentEventPublisher.publishPaymentEvent(pencil.getMember(), pencil.getPrice(), pencil.getPurchaseQuantity(), + TransactionStatus.REFUNDED); + } + + public void executeRefund(PencilAccount buyerAccount, long pencilQuantity, long price) { + long purchasedToUse = Math.min(buyerAccount.getPurchasedBalance(), pencilQuantity); + buyerAccount.decreasePurchasedBalance(purchasedToUse); + + long remaining = pencilQuantity - purchasedToUse; + long acquiredToUse = Math.min(buyerAccount.getAcquiredBalance(), remaining); + buyerAccount.decreaseAcquiredBalance(acquiredToUse); + + buyerAccount.increaseTotalRefundAmount(price); + // 남은 수량이 0이 아닐 경우 로그 기록 + if (remaining - acquiredToUse > 0) { + log.warn("Not enough balance to fully refund {} pencils. Refunded only {}.", pencilQuantity, + (purchasedToUse + acquiredToUse)); + } + } } diff --git a/src/main/java/umc/th/juinjang/domain/limjang/model/Limjang.java b/src/main/java/umc/th/juinjang/domain/limjang/model/Limjang.java index 4e218ef2..445c926c 100644 --- a/src/main/java/umc/th/juinjang/domain/limjang/model/Limjang.java +++ b/src/main/java/umc/th/juinjang/domain/limjang/model/Limjang.java @@ -1,5 +1,7 @@ package umc.th.juinjang.domain.limjang.model; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; import java.util.ArrayList; import java.util.List; @@ -24,6 +26,7 @@ import lombok.Builder; import lombok.Getter; import lombok.NoArgsConstructor; +import lombok.Setter; import umc.th.juinjang.domain.checklist.model.ChecklistAnswer; import umc.th.juinjang.domain.common.BaseEntity; import umc.th.juinjang.domain.image.model.Image; @@ -33,6 +36,7 @@ @Entity @Getter +@Setter @Builder @NoArgsConstructor(access = AccessLevel.PROTECTED) @AllArgsConstructor @@ -180,10 +184,26 @@ public static Limjang create(Member member, LimjangPrice price, LimjangPurpose p .build(); } + public static Limjang initNote(Member member, LimjangPrice price, LimjangPurpose purpose, + LimjangPropertyType propertyType, LimjangPriceType priceType) { + String nickname = LocalDateTime.now() + .format(DateTimeFormatter.ofPattern("yy.MM.dd HH:mm")) + " 매물노트"; + + return Limjang.builder() + .memberId(member) + .limjangPrice(price) + .priceType(priceType) + .purpose(purpose) + .propertyType(propertyType) + .nickname(nickname) + .build(); + } + public void updateNote(String nickname, LimjangPriceType limjangPriceType, String floor, int pyong) { this.nickname = nickname; this.priceType = limjangPriceType; this.floor = floor; this.pyong = pyong; } + } diff --git a/src/main/java/umc/th/juinjang/domain/limjang/model/LimjangPurpose.java b/src/main/java/umc/th/juinjang/domain/limjang/model/LimjangPurpose.java index 432a817a..f9af200f 100644 --- a/src/main/java/umc/th/juinjang/domain/limjang/model/LimjangPurpose.java +++ b/src/main/java/umc/th/juinjang/domain/limjang/model/LimjangPurpose.java @@ -1,30 +1,31 @@ package umc.th.juinjang.domain.limjang.model; import java.util.Arrays; + import umc.th.juinjang.common.code.status.ErrorStatus; import umc.th.juinjang.common.exception.handler.LimjangHandler; public enum LimjangPurpose { - INVESTMENT(0), // 투자 목적 - RESIDENTIAL_PURPOSE(1); // 거주 목적 + INVESTMENT(0), // 투자 목적 + RESIDENTIAL_PURPOSE(1); // 거주 목적, 직접 입주 - private final int value; + private final int value; - LimjangPurpose(int value) { - this.value = value; - } + LimjangPurpose(int value) { + this.value = value; + } - // 숫자 리턴 - public int getValue() { - return value; - } + // 숫자 리턴 + public int getValue() { + return value; + } - public static LimjangPurpose find(int inputValue) { - return Arrays.stream(LimjangPurpose.values()) - .filter(it -> it.value == inputValue) - .findAny() - .orElseThrow(() -> new LimjangHandler(ErrorStatus.LIMJANG_POST_TYPE_ERROR)); - } + public static LimjangPurpose find(int inputValue) { + return Arrays.stream(LimjangPurpose.values()) + .filter(it -> it.value == inputValue) + .findAny() + .orElseThrow(() -> new LimjangHandler(ErrorStatus.LIMJANG_POST_TYPE_ERROR)); + } } diff --git a/src/main/java/umc/th/juinjang/domain/limjang/repository/LimjangQueryDslRepositoryImpl.java b/src/main/java/umc/th/juinjang/domain/limjang/repository/LimjangQueryDslRepositoryImpl.java index 1943c935..892e14ad 100644 --- a/src/main/java/umc/th/juinjang/domain/limjang/repository/LimjangQueryDslRepositoryImpl.java +++ b/src/main/java/umc/th/juinjang/domain/limjang/repository/LimjangQueryDslRepositoryImpl.java @@ -1,11 +1,14 @@ package umc.th.juinjang.domain.limjang.repository; -import static com.querydsl.core.types.Order.DESC; -import static umc.th.juinjang.domain.image.model.QImage.image; -import static umc.th.juinjang.domain.limjang.model.QLimjang.limjang; -import static umc.th.juinjang.domain.limjang.model.QLimjangPrice.limjangPrice; -import static umc.th.juinjang.domain.report.model.QReport.report; -import static umc.th.juinjang.domain.limjang.model.QAddress.address; +import static com.querydsl.core.types.Order.*; +import static umc.th.juinjang.domain.image.model.QImage.*; +import static umc.th.juinjang.domain.limjang.model.QAddress.*; +import static umc.th.juinjang.domain.limjang.model.QLimjang.*; +import static umc.th.juinjang.domain.limjang.model.QLimjangPrice.*; +import static umc.th.juinjang.domain.report.model.QReport.*; + +import java.util.ArrayList; +import java.util.List; import com.querydsl.core.types.OrderSpecifier; import com.querydsl.core.types.dsl.BooleanExpression; @@ -15,10 +18,6 @@ import com.querydsl.jpa.impl.JPAQueryFactory; import jakarta.persistence.EntityManager; - -import java.util.ArrayList; -import java.util.List; - import umc.th.juinjang.api.limjang.controller.parameter.LimjangSortOptions; import umc.th.juinjang.domain.limjang.model.Limjang; import umc.th.juinjang.domain.member.model.Member; @@ -70,7 +69,7 @@ public List findAllByMemberAndDeletedIsFalseOrderByParamV2(Member membe return queryFactory .selectFrom(limjang) .join(limjang.limjangPrice, limjangPrice).fetchJoin() - .join(limjang.addressEntity, address).fetchJoin() + .leftJoin(limjang.addressEntity, address).fetchJoin() .leftJoin(limjang.report, report).fetchJoin() .where(limjang.memberId.eq(member)) .where(limjang.deleted.isFalse()) diff --git a/src/main/java/umc/th/juinjang/domain/limjang/repository/LimjangRepository.java b/src/main/java/umc/th/juinjang/domain/limjang/repository/LimjangRepository.java index 8341a45c..5f6354f7 100644 --- a/src/main/java/umc/th/juinjang/domain/limjang/repository/LimjangRepository.java +++ b/src/main/java/umc/th/juinjang/domain/limjang/repository/LimjangRepository.java @@ -57,7 +57,7 @@ Optional findByLimjangIdAndMemberIdWithLimjangPriceAndDeletedIsFalse(@P @Query("SELECT l FROM Limjang l WHERE l.limjangId = :id AND l.deleted = false") Optional findByLimjangIdAndDeletedIsFalse(@Param("id") Long id); - @Query("SELECT l FROM Limjang l join fetch l.addressEntity join fetch l.limjangPrice WHERE l.limjangId = :id AND l.deleted = false") + @Query("SELECT l FROM Limjang l left join fetch l.addressEntity left join fetch l.limjangPrice WHERE l.limjangId = :id AND l.deleted = false") Optional findByIdWithAddressAndNotePriceWhereDeletedIsFalse(@Param("id") Long id); @Query("SELECT l FROM Limjang l join fetch l.addressEntity join fetch l.limjangPrice left join fetch l.report WHERE l.memberId = :member AND l.deleted = false AND l.isSharable = true") diff --git a/src/test/java/umc/th/juinjang/api/ControllerTestSupport.java b/src/test/java/umc/th/juinjang/api/ControllerTestSupport.java index 1e5f2cb3..e1d97b3e 100644 --- a/src/test/java/umc/th/juinjang/api/ControllerTestSupport.java +++ b/src/test/java/umc/th/juinjang/api/ControllerTestSupport.java @@ -7,6 +7,9 @@ import com.fasterxml.jackson.databind.ObjectMapper; +import umc.th.juinjang.api.limjang.controller.NoteControllerV2; +import umc.th.juinjang.api.limjang.service.NoteCommandServiceV2; +import umc.th.juinjang.api.limjang.service.NoteQueryServiceV2; import umc.th.juinjang.api.pencil.controller.PencilController; import umc.th.juinjang.api.pencil.service.PencilCommandService; import umc.th.juinjang.api.pencil.service.PencilQueryService; @@ -15,7 +18,8 @@ @WebMvcTest(controllers = { PencilController.class, - PencilAccountController.class + PencilAccountController.class, + NoteControllerV2.class }) public abstract class ControllerTestSupport { @@ -33,4 +37,10 @@ public abstract class ControllerTestSupport { @MockBean protected PencilCommandService pencilCommandService; + + @MockBean + protected NoteCommandServiceV2 noteCommandServiceV2; + + @MockBean + protected NoteQueryServiceV2 noteQueryServiceV2; } diff --git a/src/test/java/umc/th/juinjang/api/limjang/service/command/InitNoteTest.java b/src/test/java/umc/th/juinjang/api/limjang/service/command/InitNoteTest.java new file mode 100644 index 00000000..139100d6 --- /dev/null +++ b/src/test/java/umc/th/juinjang/api/limjang/service/command/InitNoteTest.java @@ -0,0 +1,271 @@ +package umc.th.juinjang.api.limjang.service.command; + +import static org.assertj.core.api.Assertions.*; +import static org.junit.jupiter.api.Assertions.*; + +import java.util.List; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +import jakarta.transaction.Transactional; +import lombok.extern.slf4j.Slf4j; +import umc.th.juinjang.api.IntegrationTestSupport; +import umc.th.juinjang.api.limjang.controller.parameter.LimjangSortOptions; +import umc.th.juinjang.api.limjang.controller.request.NoteInitRequest; +import umc.th.juinjang.api.limjang.controller.request.NotePatchRequest; +import umc.th.juinjang.api.limjang.service.NoteCommandServiceV2; +import umc.th.juinjang.api.limjang.service.NoteQueryServiceV2; +import umc.th.juinjang.api.limjang.service.response.NotePostResponse; +import umc.th.juinjang.api.limjang.service.response.UserNoteGetResponse; +import umc.th.juinjang.api.limjang.service.response.UserNotesGetResponse; +import umc.th.juinjang.domain.limjang.model.Limjang; +import umc.th.juinjang.domain.limjang.model.LimjangPriceType; +import umc.th.juinjang.domain.limjang.model.LimjangPropertyType; +import umc.th.juinjang.domain.limjang.model.LimjangPurpose; +import umc.th.juinjang.domain.limjang.repository.LimjangRepository; +import umc.th.juinjang.domain.member.model.Member; +import umc.th.juinjang.domain.member.repository.MemberRepository; + +@Slf4j +@DisplayName("노트 초기화 테스트") +public class InitNoteTest extends IntegrationTestSupport { + + @Autowired + private MemberRepository memberRepository; + @Autowired + private NoteCommandServiceV2 noteCommandService; + @Autowired + private NoteQueryServiceV2 noteQueryService; + @Autowired + private LimjangRepository limjangRepository; + + private Member firstMember; + private Member secondMember; + private Member thirdMember; + + @BeforeEach + void setUp() { + flushAndTestUsers(); + } + + @Nested + @DisplayName("간소화 된 생성을 했을 경우") + class InitNotes { // 괄호 제거! + + private NotePostResponse noteInitResponse; + + @BeforeEach + void setUpNote() { + NoteInitRequest request = new NoteInitRequest( + LimjangPurpose.RESIDENTIAL_PURPOSE, + LimjangPropertyType.APARTMENT, + LimjangPriceType.MONTHLY_RENT, + "50000", + "4000" + ); + noteInitResponse = noteCommandService.initNote(request, firstMember); + } + + @Test + @DisplayName("UX 리팩토링을 위해, 새로운 노트 생성이 정상적으로 작동하는 가 ?") + void initNote() { + // given + Long createNoteId = noteInitResponse.noteId(); + + // when + Limjang note = limjangRepository.findById(createNoteId).orElseThrow(); + + // then + assertAll( + () -> assertThat(note.getMemberId().getMemberId()).isEqualTo(firstMember.getMemberId()), + () -> assertThat(note.getPurpose()).isEqualTo(LimjangPurpose.RESIDENTIAL_PURPOSE), + () -> assertThat(note.getPropertyType()).isEqualTo(LimjangPropertyType.APARTMENT), + () -> assertThat(note.getPriceType()).isEqualTo(LimjangPriceType.MONTHLY_RENT), + () -> assertThat(note.getRecordCount()).isEqualTo(0), + () -> assertThat(note.getLimjangPrice()).isNotNull(), + () -> assertThat(note.getNickname()).endsWith(" 매물노트") + ); + } + + @Test + @DisplayName("initNote로 생성된 노트가 리스트에 정상 노출") + void initNoteAreVisibleInList() { + // given + log.info("=== 노트 생성 시작 ==="); + Long createdNoteId = noteInitResponse.noteId(); + log.info("생성된 노트 ID: {}", createdNoteId); + + // when + log.info("=== 노트 조회 시작 ==="); + UserNotesGetResponse response = noteQueryService.findUsersNotes( + firstMember, LimjangSortOptions.CREATED, "" + ); + log.info("조회된 노트 개수: {}", response.notes().size()); + + // then + assertThat(response.notes()).hasSize(1); + + UserNotesGetResponse.UserNoteResponse note = response.notes().get(0); + log.info("=== 노트 정보 ==="); + log.info("### notes : {} ", note); + log.info("노트 ID: {}", note.noteId()); + log.info("노트 이름: {}", note.name()); + log.info("가격: {}", note.price()); + log.info("월세: {}", note.monthlyRent()); + + assertAll( + () -> assertInitNoteBasicFields(note, createdNoteId), + () -> assertInitNoteEmptyFields(note) + ); + } + + @Test + @DisplayName("initNote로 생성된 노트의 상세가 정상 노출") + void initNoteDetailAreVisible() { + // given + Long createdNoteId = noteInitResponse.noteId(); + + // when + UserNoteGetResponse response = noteQueryService.findNote(createdNoteId); + log.info("### response : {} ", response); + + // then + assertInitNoteBasicFields(response, createdNoteId); + } + + @Test + @Transactional + @DisplayName("생성된 노트가 정상적으로 수정이 되는 가") + void noteByInited_CanbeUpdate() { + // given + Long createdNoteId = noteInitResponse.noteId(); + + NotePatchRequest updateRequest = new NotePatchRequest( + LimjangPriceType.MONTHLY_RENT, + "60000", // 보증금 변경 + "5000", // 월세 변경 + "서울특별시 강남구 테헤란로 123", // 도로명 주소 + "101동 101호", // 상세 주소 + "1168010100", // 법정동코드 + "강남 아파트 매물노트", // 닉네임 변경 + "5", // 층수 + 32, // 평수 + "서울특별시", // 시도 + "강남구", // 시군구 + "역삼동", // 법정동명1 + "" // 법정동명2 + ); + + // when + noteCommandService.updateNoteV2(createdNoteId, updateRequest); + + // then + Limjang updatedNote = limjangRepository.findById(createdNoteId).orElseThrow(); + + assertAll( + () -> assertThat(updatedNote.getNickname()).isEqualTo("강남 아파트 매물노트"), + () -> assertThat(updatedNote.getPriceType()).isEqualTo(LimjangPriceType.MONTHLY_RENT), + () -> assertThat(updatedNote.getFloor()).isEqualTo("5"), + () -> assertThat(updatedNote.getPyong()).isEqualTo(32), + () -> assertThat(updatedNote.getLimjangPrice()).isNotNull(), + () -> assertThat(updatedNote.getLimjangPrice().getMonthlyRent()).isEqualTo("5000"), + () -> assertThat(updatedNote.getAddressEntity()).isNotNull(), + () -> assertThat(updatedNote.getAddressEntity().getRoadAddress()).isEqualTo("서울특별시 강남구 테헤란로 123"), + () -> assertThat(updatedNote.getAddressEntity().getAddressDetail()).isEqualTo("101동 101호") + ); + } + + @Test + @Transactional + @DisplayName("생성된 노트가 정상적으로 삭제가 되는 가") + void noteByInited_CanbeDeleted() { + // given + Long createdNoteId = noteInitResponse.noteId(); + Limjang note = limjangRepository.findById(createdNoteId).orElseThrow(); + + // when + limjangRepository.softDeleteByIds(List.of(createdNoteId)); + limjangRepository.flush(); + + // then + Limjang deletedNote = limjangRepository.findById(createdNoteId).orElseThrow(); + + // 삭제된 노트는 리스트에서 조회되지 않아야 함 + UserNotesGetResponse response = noteQueryService.findUsersNotes( + firstMember, LimjangSortOptions.CREATED, "" + ); + assertThat(response.notes()).isEmpty(); + } + } + + private void assertInitNoteBasicFields(UserNotesGetResponse.UserNoteResponse note, Long expectedId) { + assertAll( + () -> assertThat(note.noteId()).isEqualTo(expectedId), + () -> assertThat(note.purposeType()).isEqualTo(LimjangPurpose.RESIDENTIAL_PURPOSE), + () -> assertThat(note.propertyType()).isEqualTo(LimjangPropertyType.APARTMENT), + () -> assertThat(note.priceType()).isEqualTo(LimjangPriceType.MONTHLY_RENT), + () -> assertThat(note.isScraped()).isFalse(), + () -> assertThat(note.name()).endsWith(" 매물노트"), + () -> assertThat(note.price()).isEqualTo("50000"), + () -> assertThat(note.monthlyRent()).isEqualTo("4000") + ); + } + + private void assertInitNoteBasicFields(UserNoteGetResponse note, Long expectedId) { + assertAll( + () -> assertThat(note.purposeType()).isEqualTo(LimjangPurpose.RESIDENTIAL_PURPOSE), + () -> assertThat(note.propertyType()).isEqualTo(LimjangPropertyType.APARTMENT), + () -> assertThat(note.priceType()).isEqualTo(LimjangPriceType.MONTHLY_RENT), + () -> assertThat(note.price()).isEqualTo("50000"), + () -> assertThat(note.monthlyRent()).isEqualTo("4000") + ); + } + + private void assertInitNoteEmptyFields(UserNotesGetResponse.UserNoteResponse note) { + assertAll( + () -> assertThat(note.address()).isNull(), + () -> assertThat(note.shortAddress()).isNull(), + () -> assertThat(note.pyong()).isNull(), + () -> assertThat(note.floor()).isNull(), + () -> assertThat(note.rate()).isNull(), + () -> assertThat(note.imageUrl()).isEmpty() + ); + } + + private void flushAndTestUsers() { + memberRepository.deleteAll(); + + // 첫 번째 멤버 (Apple) + firstMember = Member.createAppleMember( + "first@apple.com", + "apple_sub_001", + "첫번째유저", + "1.0.0" + ); + memberRepository.save(firstMember); + + // 두 번째 멤버 (Kakao) + secondMember = Member.createKakaoMember( + "second@kakao.com", + 12345L, + "두번째유저", + "1.0.0" + ); + memberRepository.save(secondMember); + + // 세 번째 멤버 (Apple) + thirdMember = Member.createAppleMember( + "third@apple.com", + "apple_sub_002", + "세번째유저", + "1.0.0" + ); + memberRepository.save(thirdMember); + + memberRepository.flush(); + } +} \ No newline at end of file diff --git a/src/test/java/umc/th/juinjang/api/limjang/service/command/NoteCreateTest.java b/src/test/java/umc/th/juinjang/api/limjang/service/command/NoteCreateTest.java new file mode 100644 index 00000000..86d08de8 --- /dev/null +++ b/src/test/java/umc/th/juinjang/api/limjang/service/command/NoteCreateTest.java @@ -0,0 +1,273 @@ +package umc.th.juinjang.api.limjang.service.command; + +import static org.assertj.core.api.Assertions.*; +import static org.junit.jupiter.api.Assertions.*; + +import java.util.ArrayList; +import java.util.List; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +import jakarta.transaction.Transactional; +import lombok.extern.slf4j.Slf4j; +import umc.th.juinjang.api.IntegrationTestSupport; +import umc.th.juinjang.api.limjang.controller.parameter.LimjangSortOptions; +import umc.th.juinjang.api.limjang.controller.request.LimjangsDeleteRequest; +import umc.th.juinjang.api.limjang.controller.request.NoteInitRequest; +import umc.th.juinjang.api.limjang.controller.request.NotePatchRequest; +import umc.th.juinjang.api.limjang.service.LimjangCommandService; +import umc.th.juinjang.api.limjang.service.NoteCommandServiceV2; +import umc.th.juinjang.api.limjang.service.NoteQueryServiceV2; +import umc.th.juinjang.api.limjang.service.response.NotePostResponse; +import umc.th.juinjang.api.limjang.service.response.UserNoteGetResponse; +import umc.th.juinjang.api.limjang.service.response.UserNotesGetResponse; +import umc.th.juinjang.common.exception.handler.LimjangHandler; +import umc.th.juinjang.domain.limjang.model.Limjang; +import umc.th.juinjang.domain.limjang.model.LimjangPriceType; +import umc.th.juinjang.domain.limjang.model.LimjangPropertyType; +import umc.th.juinjang.domain.limjang.model.LimjangPurpose; +import umc.th.juinjang.domain.limjang.repository.LimjangRepository; +import umc.th.juinjang.domain.member.model.Member; +import umc.th.juinjang.domain.member.repository.MemberRepository; + +@Slf4j +public class NoteCreateTest extends IntegrationTestSupport { + + @Autowired + private MemberRepository memberRepository; + @Autowired + private NoteCommandServiceV2 noteCommandService; + @Autowired + private LimjangCommandService limjangCommandService; + + @Autowired + private NoteQueryServiceV2 noteQueryService; + @Autowired + private LimjangRepository limjangRepository; + + private Member firstMember; + private Member secondMember; + private Member thirdMember; + + @BeforeEach + void setUp() { + flushAndTestUsers(); + } + + @DisplayName("새로운 노트를 생성할 때, ") + @Nested + class CreateNote { + + private NotePostResponse noteInitResponse; + + @BeforeEach + void setUpNote() { + // given + NoteInitRequest request = new NoteInitRequest( + LimjangPurpose.RESIDENTIAL_PURPOSE, + LimjangPropertyType.APARTMENT, + LimjangPriceType.MONTHLY_RENT, + "50000", + "4000" + ); + + // when + noteInitResponse = noteCommandService.initNote(request, firstMember); + } + + @DisplayName("올바른 요청이 주어지면, 정상적으로 생성된다") + @Test + void createsNote_whenValidRequestIsProvided() { + // given + Long createNoteId = noteInitResponse.noteId(); + + // when + Limjang note = limjangRepository.findById(createNoteId).orElseThrow(); + + // then + assertAll( + () -> assertThat(note.getMemberId().getMemberId()).isEqualTo(firstMember.getMemberId()), + () -> assertThat(note.getPurpose()).isEqualTo(LimjangPurpose.RESIDENTIAL_PURPOSE), + () -> assertThat(note.getPropertyType()).isEqualTo(LimjangPropertyType.APARTMENT), + () -> assertThat(note.getPriceType()).isEqualTo(LimjangPriceType.MONTHLY_RENT), + () -> assertThat(note.getRecordCount()).isEqualTo(0), + () -> assertThat(note.getLimjangPrice()).isNotNull(), + () -> assertThat(note.getNickname()).endsWith(" 매물노트") + ); + } + + @DisplayName("생성된 노트가 리스트에 정상적으로 노출된다") + @Test + void createdNoteIsVisible_inList() { + // given + Long createdNoteId = noteInitResponse.noteId(); + log.info("생성된 노트 ID: {}", createdNoteId); + + // when + log.info("=== 노트 조회 시작 ==="); + UserNotesGetResponse response = noteQueryService.findUsersNotes( + firstMember, LimjangSortOptions.CREATED, "" + ); + log.info("조회된 노트 개수: {}", response.notes().size()); + + // then + assertThat(response.notes()).hasSize(1); + + UserNotesGetResponse.UserNoteResponse note = response.notes().get(0); + log.info("=== 노트 정보 ==="); + log.info("### notes : {} ", note); + log.info("노트 ID: {}", note.noteId()); + log.info("노트 이름: {}", note.name()); + log.info("가격: {}", note.price()); + log.info("월세: {}", note.monthlyRent()); + + assertAll( + () -> assertInitNoteBasicFields(note, createdNoteId), + () -> assertInitNoteEmptyFields(note) + ); + } + + @DisplayName("생성된 노트의 상세가 정상적으로 노출된다") + @Test + void createdNoteDetailIsVisible() { + // given + Long createdNoteId = noteInitResponse.noteId(); + + // when + UserNoteGetResponse response = noteQueryService.findNote(createdNoteId); + log.info("### response : {} ", response); + + // then + assertInitNoteBasicFields(response, createdNoteId); + } + + @DisplayName("생성된 노트가 정상적으로 수정된다") + @Test + @Transactional + void updateNoteSuccess_whenCreateNote() { + // given + Long createNoteId = noteInitResponse.noteId(); + + NotePatchRequest updateRequest = new NotePatchRequest( + LimjangPriceType.MONTHLY_RENT, + "60000", // 보증금 변경 + "5000", // 월세 변경 + "서울특별시 강남구 테헤란로 123", // 도로명 주소 + "101동 101호", // 상세 주소 + "1168010100", // 법정동코드 + "강남 아파트 매물노트", // 닉네임 변경 + "5", // 층수 + 32, // 평수 + "서울특별시", // 시도 + "강남구", // 시군구 + "역삼동", // 법정동명1 + "" // 법정동명2 + ); + + // when + noteCommandService.updateNoteV2(createNoteId, updateRequest); + + // then + Limjang updatedNote = limjangRepository.findById(createNoteId).orElseThrow(); + + assertAll( + () -> assertThat(updatedNote.getNickname()).isEqualTo("강남 아파트 매물노트"), + () -> assertThat(updatedNote.getPriceType()).isEqualTo(LimjangPriceType.MONTHLY_RENT), + () -> assertThat(updatedNote.getFloor()).isEqualTo("5"), + () -> assertThat(updatedNote.getPyong()).isEqualTo(32), + () -> assertThat(updatedNote.getLimjangPrice()).isNotNull(), + () -> assertThat(updatedNote.getLimjangPrice().getMonthlyRent()).isEqualTo("5000"), + () -> assertThat(updatedNote.getAddressEntity()).isNotNull(), + () -> assertThat(updatedNote.getAddressEntity().getRoadAddress()).isEqualTo("서울특별시 강남구 테헤란로 123"), + () -> assertThat(updatedNote.getAddressEntity().getAddressDetail()).isEqualTo("101동 101호") + ); + } + + @DisplayName("생성된 노트가 정상적으로 삭제된다") + @Test + void deleteNoteSuccessCreateByNote() { + // given + long createNoteId = noteInitResponse.noteId(); + List noteIds = new ArrayList<>(); + noteIds.add(createNoteId); + + // when + limjangCommandService.deleteLimjangs(LimjangsDeleteRequest.of(noteIds), firstMember); + + // then + assertThrows(LimjangHandler.class, () -> noteQueryService.findNote(createNoteId)); + } + } + + // Helper methods + private void assertInitNoteBasicFields(UserNotesGetResponse.UserNoteResponse note, Long expectedId) { + assertAll( + () -> assertThat(note.noteId()).isEqualTo(expectedId), + () -> assertThat(note.purposeType()).isEqualTo(LimjangPurpose.RESIDENTIAL_PURPOSE), + () -> assertThat(note.propertyType()).isEqualTo(LimjangPropertyType.APARTMENT), + () -> assertThat(note.priceType()).isEqualTo(LimjangPriceType.MONTHLY_RENT), + () -> assertThat(note.isScraped()).isFalse(), + () -> assertThat(note.name()).endsWith(" 매물노트"), + () -> assertThat(note.price()).isEqualTo("50000"), + () -> assertThat(note.monthlyRent()).isEqualTo("4000") + ); + } + + private void assertInitNoteBasicFields(UserNoteGetResponse note, Long expectedId) { + assertAll( + () -> assertThat(note.purposeType()).isEqualTo(LimjangPurpose.RESIDENTIAL_PURPOSE), + () -> assertThat(note.propertyType()).isEqualTo(LimjangPropertyType.APARTMENT), + () -> assertThat(note.priceType()).isEqualTo(LimjangPriceType.MONTHLY_RENT), + () -> assertThat(note.price()).isEqualTo("50000"), + () -> assertThat(note.monthlyRent()).isEqualTo("4000") + ); + } + + private void assertInitNoteEmptyFields(UserNotesGetResponse.UserNoteResponse note) { + assertAll( + () -> assertThat(note.address()).isNull(), + () -> assertThat(note.shortAddress()).isNull(), + () -> assertThat(note.pyong()).isNull(), + () -> assertThat(note.floor()).isNull(), + () -> assertThat(note.rate()).isNull(), + () -> assertThat(note.imageUrl()).isEmpty() + ); + } + + private void flushAndTestUsers() { + memberRepository.deleteAll(); + + // 첫 번째 멤버 (Apple) + firstMember = Member.createAppleMember( + "first@apple.com", + "apple_sub_001", + "첫번째유저", + "1.0.0" + ); + memberRepository.save(firstMember); + + // 두 번째 멤버 (Kakao) + secondMember = Member.createKakaoMember( + "second@kakao.com", + 12345L, + "두번째유저", + "1.0.0" + ); + memberRepository.save(secondMember); + + // 세 번째 멤버 (Apple) + thirdMember = Member.createAppleMember( + "third@apple.com", + "apple_sub_002", + "세번째유저", + "1.0.0" + ); + memberRepository.save(thirdMember); + + memberRepository.flush(); + } +} \ No newline at end of file diff --git a/src/test/java/umc/th/juinjang/api/pencil/service/PencilCommandServiceTest.java b/src/test/java/umc/th/juinjang/api/pencil/service/PencilCommandServiceTest.java index 6dac1e79..f551c771 100644 --- a/src/test/java/umc/th/juinjang/api/pencil/service/PencilCommandServiceTest.java +++ b/src/test/java/umc/th/juinjang/api/pencil/service/PencilCommandServiceTest.java @@ -1,26 +1,20 @@ package umc.th.juinjang.api.pencil.service; -import static org.assertj.core.api.Assertions.*; -import static org.mockito.Mockito.*; - +import com.apple.itunes.storekit.client.APIException; +import com.apple.itunes.storekit.model.JWSTransactionDecodedPayload; +import com.apple.itunes.storekit.verification.VerificationException; import java.io.IOException; import java.time.LocalDateTime; import java.util.UUID; - +import lombok.extern.slf4j.Slf4j; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; import org.springframework.boot.test.mock.mockito.MockBean; - -import com.apple.itunes.storekit.client.APIException; -import com.apple.itunes.storekit.model.JWSTransactionDecodedPayload; -import com.apple.itunes.storekit.verification.VerificationException; - -import lombok.extern.slf4j.Slf4j; import umc.th.juinjang.api.IntegrationTestSupport; -import umc.th.juinjang.api.apple.service.AppleService; +import umc.th.juinjang.api.apple.service.AppleServiceImpl; import umc.th.juinjang.api.apple.service.command.AppleTransactionVerifyCommand; import umc.th.juinjang.api.pencil.controller.request.AppleIAPPurchaseRequest; import umc.th.juinjang.api.pencil.service.response.AppleIAPPurchaseResponse; @@ -33,113 +27,116 @@ import umc.th.juinjang.domain.pencilaccount.repository.PencilAccountRepository; import umc.th.juinjang.testutil.fixture.MemberFixture; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.any; +import static org.mockito.Mockito.when; + @Slf4j public class PencilCommandServiceTest extends IntegrationTestSupport { - @Value("${apple.iap.bundle-id}") - private String bundleId; - - private final String transactionId = "transactionId"; - private final UUID appAccountToken = UUID.randomUUID(); - private final String productId = "productId"; - - @Autowired - private PencilCommandService pencilService; - - @Autowired - private PurchasedPencilRepository purchasedPencilRepository; - - @Autowired - private PencilAccountRepository pencilAccountRepository; - - @Autowired - private MemberRepository memberRepository; - - @MockBean - private AppleService appleService; - - @AfterEach - void tearDown() { - purchasedPencilRepository.deleteAllInBatch(); - pencilAccountRepository.deleteAllInBatch(); - memberRepository.deleteAllInBatch(); - } - - @DisplayName("애플 인앱 결제과 정상적으로 진행됩니다.") - @Test - void processAppleIAPPurchase_Success() throws APIException, VerificationException, IOException { - // given - Member member = MemberFixture.createDefaultMember(); - memberRepository.save(member); - - PencilAccount pencilAccount = PencilAccount.createPencilAccount(member); - pencilAccountRepository.save(pencilAccount); - - AppleIAPPurchaseRequest request = createValidRequest(); - LocalDateTime now = LocalDateTime.now(); - - JWSTransactionDecodedPayload payload = new JWSTransactionDecodedPayload(); - payload.setProductId(productId); - payload.setAppAccountToken(appAccountToken); - payload.setBundleId(bundleId); - payload.setQuantity(20); - payload.setTransactionId(transactionId); - - when(appleService.verifyAppleTransaction(any(AppleTransactionVerifyCommand.class))) - .thenReturn(VerificationResult.ofSuccess(payload)); - - // when - AppleIAPPurchaseResponse response = pencilService.processAppleIAPPurchase(request, member,now); - - // then - log.info("[RESPONSE - TRANSACTION_ID]: {}", response.getTransactionId()); - - assertThat(response).isNotNull(); - assertThat(response.getTransactionId()).isEqualTo(transactionId); - assertThat(response.getStatus()).isEqualTo(TransactionStatus.SUCCESS); - } - - @DisplayName("애플 인앱 결제 중에 유효성 검증에서 실패한 경우 (트랜잭션 아이디가 불일치) 에 대한 테스트 진행") - @Test - void processAppleIAPPurchase_Validation_Fail() throws APIException, VerificationException, IOException { - // given - Member member = MemberFixture.createDefaultMember(); - memberRepository.save(member); - - PencilAccount pencilAccount = PencilAccount.createPencilAccount(member); - pencilAccountRepository.save(pencilAccount); - - // when - AppleIAPPurchaseRequest request = createValidRequest(); - LocalDateTime now = LocalDateTime.now(); - - String payloadTransactionId = "invalidTransactionId"; - JWSTransactionDecodedPayload payload = new JWSTransactionDecodedPayload(); - payload.setProductId(productId); - payload.setAppAccountToken(appAccountToken); - payload.setBundleId(bundleId); - payload.setQuantity(20); - payload.setTransactionId(payloadTransactionId); - - // then - when(appleService.verifyAppleTransaction(any(AppleTransactionVerifyCommand.class))) - .thenReturn(VerificationResult.ofVerificationError()); - - // when - AppleIAPPurchaseResponse response = pencilService.processAppleIAPPurchase(request, member,now); - - // then - assertThat(response).isNotNull(); - assertThat(response.getTransactionId()).isEqualTo(transactionId); - assertThat(response.getStatus()).isEqualTo(TransactionStatus.VALIDATION_FAILED); - } - - - - private AppleIAPPurchaseRequest createValidRequest() { - return AppleIAPPurchaseRequest - .of(transactionId, appAccountToken, 20L, 3000L, productId,10); - } + @Value("${apple.iap.bundle-id}") + private String bundleId; + + private final String transactionId = "transactionId"; + private final UUID appAccountToken = UUID.randomUUID(); + private final String productId = "productId"; + + @Autowired + private PencilCommandService pencilService; + + @Autowired + private PurchasedPencilRepository purchasedPencilRepository; + + @Autowired + private PencilAccountRepository pencilAccountRepository; + + @Autowired + private MemberRepository memberRepository; + + @MockBean + private AppleServiceImpl appleServiceImpl; + + @AfterEach + void tearDown() { + purchasedPencilRepository.deleteAllInBatch(); + pencilAccountRepository.deleteAllInBatch(); + memberRepository.deleteAllInBatch(); + } + + @DisplayName("애플 인앱 결제과 정상적으로 진행됩니다.") + @Test + void processAppleIAPPurchase_Success() throws APIException, VerificationException, IOException { + // given + Member member = MemberFixture.createDefaultMember(); + memberRepository.save(member); + + PencilAccount pencilAccount = PencilAccount.createPencilAccount(member); + pencilAccountRepository.save(pencilAccount); + + AppleIAPPurchaseRequest request = createValidRequest(); + LocalDateTime now = LocalDateTime.now(); + + JWSTransactionDecodedPayload payload = new JWSTransactionDecodedPayload(); + payload.setProductId(productId); + payload.setAppAccountToken(appAccountToken); + payload.setBundleId(bundleId); + payload.setQuantity(20); + payload.setTransactionId(transactionId); + + when(appleServiceImpl.verifyAppleTransaction(any(AppleTransactionVerifyCommand.class))) + .thenReturn(VerificationResult.ofSuccess(payload)); + + // when + AppleIAPPurchaseResponse response = pencilService.processAppleIAPPurchase(request, member, now); + + // then + log.info("[RESPONSE - TRANSACTION_ID]: {}", response.getTransactionId()); + + assertThat(response).isNotNull(); + assertThat(response.getTransactionId()).isEqualTo(transactionId); + assertThat(response.getStatus()).isEqualTo(TransactionStatus.SUCCESS); + } + + @DisplayName("애플 인앱 결제 중에 유효성 검증에서 실패한 경우 (트랜잭션 아이디가 불일치) 에 대한 테스트 진행") + @Test + void processAppleIAPPurchase_Validation_Fail() throws APIException, VerificationException, IOException { + // given + Member member = MemberFixture.createDefaultMember(); + memberRepository.save(member); + + PencilAccount pencilAccount = PencilAccount.createPencilAccount(member); + pencilAccountRepository.save(pencilAccount); + + // when + AppleIAPPurchaseRequest request = createValidRequest(); + LocalDateTime now = LocalDateTime.now(); + + String payloadTransactionId = "invalidTransactionId"; + JWSTransactionDecodedPayload payload = new JWSTransactionDecodedPayload(); + payload.setProductId(productId); + payload.setAppAccountToken(appAccountToken); + payload.setBundleId(bundleId); + payload.setQuantity(20); + payload.setTransactionId(payloadTransactionId); + + // then + when(appleServiceImpl.verifyAppleTransaction(any(AppleTransactionVerifyCommand.class))) + .thenReturn(VerificationResult.ofVerificationError()); + + // when + AppleIAPPurchaseResponse response = pencilService.processAppleIAPPurchase(request, member, now); + + // then + assertThat(response).isNotNull(); + assertThat(response.getTransactionId()).isEqualTo(transactionId); + assertThat(response.getStatus()).isEqualTo(TransactionStatus.VALIDATION_FAILED); + } + + + private AppleIAPPurchaseRequest createValidRequest() { + return AppleIAPPurchaseRequest + .of(transactionId, appAccountToken, 20L, 3000L, productId, 10); + } }