Skip to content
Original file line number Diff line number Diff line change
@@ -1,13 +1,52 @@
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 umc.th.juinjang.api.apple.service.AppleService;
import umc.th.juinjang.api.pencil.service.PencilCommandService;
import umc.th.juinjang.api.pencil.service.PencilQueryService;

@RestController
@RequestMapping("/api/v2/apple")
@RequestMapping("/api/apple")
@RequiredArgsConstructor
@Slf4j
public class AppleController {

private final AppleService appleService;
private final PencilQueryService pencilQueryService;
private final PencilCommandService pencilCommandService;

@Operation(summary = "애플 서버 알림 API")
@PostMapping("notifications/v2")
public ResponseEntity<Void> handleNotificationV2(@RequestBody ResponseBodyV2 requestBody) {
ResponseBodyV2DecodedPayload payload = appleService.getNotificationPayload(requestBody);
NotificationTypeV2 type = payload.getNotificationType();

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();
}
}
42 changes: 42 additions & 0 deletions src/main/java/umc/th/juinjang/api/apple/service/AppleService.java
Original file line number Diff line number Diff line change
Expand Up @@ -6,26 +6,37 @@
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 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}")
Expand All @@ -51,6 +62,7 @@ public class AppleService {

private SignedDataVerifier signedDataVerifier;
private AppStoreServerAPIClient appStoreServerAPIClient;
private PencilQueryService pencilQueryService;

@PostConstruct
public void init() {
Expand Down Expand Up @@ -121,6 +133,35 @@ public VerificationResult verifyAppleTransaction(AppleTransactionVerifyCommand c
}
}

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) {
// 트랜잭션 아이디가 정상적으로 일치하는 지 여부
Expand Down Expand Up @@ -226,4 +267,5 @@ private String loadSigningKey() {
throw new RuntimeException("Failed to load signing key", e);
}
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,11 @@ public class AppleIAPPurchaseRequest {
private Long pencilQuantity;
private Long price;
private String productId;
private Long playTime;
private Integer playTime;

@Builder
private AppleIAPPurchaseRequest(String transactionId, UUID appAccountToken, Long pencilQuantity, Long price,
String productId , Long playTime) {
String productId , Integer playTime) {
this.transactionId = transactionId;
this.appAccountToken = appAccountToken;
this.pencilQuantity = pencilQuantity;
Expand All @@ -27,7 +27,7 @@ private AppleIAPPurchaseRequest(String transactionId, UUID appAccountToken, Long
}

public static AppleIAPPurchaseRequest of(String transactionId, UUID appAccountToken, Long pencilQuantity,
Long price, String productId, Long playTime) {
Long price, String productId, Integer playTime) {
return AppleIAPPurchaseRequest.builder()
.transactionId(transactionId)
.appAccountToken(appAccountToken)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,4 +26,8 @@ public boolean existsByMemberAndIsReadFalse(Member member) {
public AcquiredPencil findById(Long id) {
return acquiredPencilRepository.findById(id).orElse(null);
}

public boolean existsByMember(Member member) {
return acquiredPencilRepository.existsByMember(member);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
import umc.th.juinjang.domain.pencil.purchased.model.PurchasedPencil;
import umc.th.juinjang.domain.pencil.purchased.model.TransactionStatus;
import umc.th.juinjang.domain.pencilaccount.model.PencilAccount;
import umc.th.juinjang.event.publisher.PaymentEventPublisher;

@Slf4j
@Service
Expand All @@ -32,6 +33,7 @@ public class PencilCommandService {
private final PurchasedPencilFinder purchasedPencilFinder;
private final AcquiredPencilFinder acquiredPencilFinder;
private final PencilAccountFinder pencilAccountFinder;
private final PaymentEventPublisher paymentEventPublisher;

@Transactional
public Boolean markAcquiredPencilAsRead(Long acquiredPencilId) {
Expand Down Expand Up @@ -84,17 +86,17 @@ public AppleIAPPurchaseResponse validateAndCommitApplePurchase(AppleIAPPurchaseR
// 성공 시, DB에 저장
handleSuccessfulApplePurchase(request, member, now);

// TODO : 디스코드 알림 추가 필요
// paymentEventPublisher.publishPaymentEvent(member,request.getPrice(), pencilAmount,TransactionStatus.SUCCESS);
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);

// TODO : 디스코드 알림 추가 필요
// paymentEventPublisher.publishPaymentEvent(member,request.getPrice(), pencilAmount,TransactionStatus.VALIDATION_FAILED);
paymentEventPublisher.publishPaymentEvent(member, request.getPrice(), request.getPencilQuantity(),
TransactionStatus.VALIDATION_FAILED);
return AppleIAPPurchaseResponse.ofValidationFailure(transactionId);
}
}
Expand Down Expand Up @@ -122,7 +124,8 @@ public void handleFailureApplePurchase(AppleIAPPurchaseRequest request, Member m
request.getPlayTime(), transactionId, request.getAppAccountToken(), now));
}

private PurchasedPencil retryPurchasedPencil(AppleIAPPurchaseRequest request, PurchasedPencil pencil,
@Transactional
public PurchasedPencil retryPurchasedPencil(AppleIAPPurchaseRequest request, PurchasedPencil pencil,
Member member) {
if (pencil.getRetryCount() >= 3) { // 재시도 횟수가 3회 이상일 경우 실패로 처리
return pencil;
Expand All @@ -147,4 +150,35 @@ 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());

}

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));
}
}

}
Loading