Skip to content

Commit fd62157

Browse files
sonjusunsonjusun
authored andcommitted
refactor : 로컬 개발용 Apple IAP 서비스 스텁 구현 추가
1 parent 659f23b commit fd62157

File tree

6 files changed

+634
-560
lines changed

6 files changed

+634
-560
lines changed
Lines changed: 28 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,18 @@
11
package umc.th.juinjang.api.apple.controller;
22

3-
import org.springframework.http.ResponseEntity;
4-
import org.springframework.web.bind.annotation.PostMapping;
5-
import org.springframework.web.bind.annotation.RequestBody;
6-
import org.springframework.web.bind.annotation.RequestMapping;
7-
import org.springframework.web.bind.annotation.RestController;
8-
93
import com.apple.itunes.storekit.model.Data;
104
import com.apple.itunes.storekit.model.JWSTransactionDecodedPayload;
115
import com.apple.itunes.storekit.model.NotificationTypeV2;
126
import com.apple.itunes.storekit.model.ResponseBodyV2;
137
import com.apple.itunes.storekit.model.ResponseBodyV2DecodedPayload;
14-
158
import io.swagger.v3.oas.annotations.Operation;
169
import lombok.RequiredArgsConstructor;
1710
import lombok.extern.slf4j.Slf4j;
11+
import org.springframework.http.ResponseEntity;
12+
import org.springframework.web.bind.annotation.PostMapping;
13+
import org.springframework.web.bind.annotation.RequestBody;
14+
import org.springframework.web.bind.annotation.RequestMapping;
15+
import org.springframework.web.bind.annotation.RestController;
1816
import umc.th.juinjang.api.apple.service.AppleService;
1917
import umc.th.juinjang.api.pencil.service.PencilCommandService;
2018
import umc.th.juinjang.api.pencil.service.PencilQueryService;
@@ -25,29 +23,29 @@
2523
@Slf4j
2624
public class AppleController {
2725

28-
private final AppleService appleService;
29-
private final PencilQueryService pencilQueryService;
30-
private final PencilCommandService pencilCommandService;
26+
private final AppleService appleService;
27+
private final PencilQueryService pencilQueryService;
28+
private final PencilCommandService pencilCommandService;
3129

32-
@Operation(summary = "애플 서버 알림 API")
33-
@PostMapping("notifications/v2")
34-
public ResponseEntity<Void> handleNotificationV2(@RequestBody ResponseBodyV2 requestBody) {
35-
ResponseBodyV2DecodedPayload payload = appleService.getNotificationPayload(requestBody);
36-
NotificationTypeV2 type = payload.getNotificationType();
37-
log.info("### Notification Type: {}", type);
30+
@Operation(summary = "애플 서버 알림 API")
31+
@PostMapping("notifications/v2")
32+
public ResponseEntity<Void> handleNotificationV2(@RequestBody ResponseBodyV2 requestBody) {
33+
ResponseBodyV2DecodedPayload payload = appleService.getNotificationPayload(requestBody);
34+
NotificationTypeV2 type = payload.getNotificationType();
35+
log.info("### Notification Type: {}", type);
3836

39-
Data data = payload.getData();
40-
JWSTransactionDecodedPayload transactionPayload =
41-
appleService.getSignedTransactionPayload(data);
42-
if (type == NotificationTypeV2.CONSUMPTION_REQUEST) {
43-
log.info("Apple IAP Consumption Request Notification Received.");
44-
String transactionId = transactionPayload.getTransactionId();
45-
appleService.sendConsumptionData(transactionId, pencilQueryService.getConsumptionRequest(transactionId));
46-
} else if (type == NotificationTypeV2.REFUND) {
47-
log.info("Apple IAP ReFund Notification Received.");
48-
String transactionId = transactionPayload.getOriginalTransactionId();
49-
pencilCommandService.handleRefundPurchase(transactionId);
50-
}
51-
return ResponseEntity.ok().build();
52-
}
37+
Data data = payload.getData();
38+
JWSTransactionDecodedPayload transactionPayload =
39+
appleService.getSignedTransactionPayload(data);
40+
if (type == NotificationTypeV2.CONSUMPTION_REQUEST) {
41+
log.info("Apple IAP Consumption Request Notification Received.");
42+
String transactionId = transactionPayload.getTransactionId();
43+
appleService.sendConsumptionData(transactionId, pencilQueryService.getConsumptionRequest(transactionId));
44+
} else if (type == NotificationTypeV2.REFUND) {
45+
log.info("Apple IAP ReFund Notification Received.");
46+
String transactionId = transactionPayload.getOriginalTransactionId();
47+
pencilCommandService.handleRefundPurchase(transactionId);
48+
}
49+
return ResponseEntity.ok().build();
50+
}
5351
}
Lines changed: 8 additions & 252 deletions
Original file line numberDiff line numberDiff line change
@@ -1,271 +1,27 @@
11
package umc.th.juinjang.api.apple.service;
22

3-
import java.io.IOException;
4-
import java.io.InputStream;
5-
import java.util.HashSet;
6-
import java.util.Set;
7-
8-
import org.springframework.beans.factory.annotation.Value;
9-
import org.springframework.context.annotation.Profile;
10-
import org.springframework.core.io.ClassPathResource;
11-
import org.springframework.retry.annotation.Backoff;
12-
import org.springframework.retry.annotation.Retryable;
13-
import org.springframework.stereotype.Service;
14-
153
import com.apple.itunes.storekit.client.APIException;
16-
import com.apple.itunes.storekit.client.AppStoreServerAPIClient;
174
import com.apple.itunes.storekit.model.ConsumptionRequest;
185
import com.apple.itunes.storekit.model.Data;
19-
import com.apple.itunes.storekit.model.Environment;
206
import com.apple.itunes.storekit.model.JWSTransactionDecodedPayload;
217
import com.apple.itunes.storekit.model.ResponseBodyV2;
228
import com.apple.itunes.storekit.model.ResponseBodyV2DecodedPayload;
23-
import com.apple.itunes.storekit.model.TransactionInfoResponse;
24-
import com.apple.itunes.storekit.verification.SignedDataVerifier;
259
import com.apple.itunes.storekit.verification.VerificationException;
26-
27-
import jakarta.annotation.PostConstruct;
28-
import lombok.RequiredArgsConstructor;
29-
import lombok.extern.slf4j.Slf4j;
10+
import java.io.IOException;
3011
import umc.th.juinjang.api.apple.service.command.AppleTransactionVerifyCommand;
31-
import umc.th.juinjang.api.pencil.service.PencilQueryService;
3212
import umc.th.juinjang.api.pencil.service.response.VerificationResult;
33-
import umc.th.juinjang.common.code.status.ErrorStatus;
34-
import umc.th.juinjang.common.exception.handler.AppleHandler;
35-
36-
@Slf4j
37-
@Service
38-
@Profile("!local")
39-
@RequiredArgsConstructor
40-
public class AppleService {
41-
42-
@Value("${apple.iap.bundle-id}")
43-
private String bundleId;
44-
45-
@Value("${apple.iap.key-id}")
46-
private String keyId;
47-
48-
@Value("${apple.iap.issuer-id}")
49-
private String issuerId;
50-
51-
@Value("${apple.iap.apple-id}")
52-
private String appleIdStr;
53-
54-
@Value("${apple.iap.environment}")
55-
private String environmentString; // SANDBOX , PRODUCTION
56-
57-
@Value("${apple.iap.certificate-names}")
58-
private String certificateConfigs;
59-
60-
@Value("${apple.iap.private-key-path}")
61-
private String privateKeyPath;
62-
63-
private SignedDataVerifier signedDataVerifier;
64-
private AppStoreServerAPIClient appStoreServerAPIClient;
65-
private PencilQueryService pencilQueryService;
66-
67-
@PostConstruct
68-
public void init() {
69-
70-
log.info("Apple IAP 초기화 시작");
71-
log.info("Bundle ID: {}", bundleId);
72-
log.info("Key ID: {}", keyId);
73-
log.info("Issuer ID: {}", issuerId);
74-
log.info("Environment: {}", environmentString);
75-
log.info("Private Key Path: {}", privateKeyPath);
76-
77-
Set<InputStream> rootCertificates = loadRootCertificates();
78-
79-
Environment environment = Environment.fromValue(environmentString);
80-
Long appleId = Long.valueOf(appleIdStr);
81-
82-
this.signedDataVerifier = new SignedDataVerifier(
83-
rootCertificates,
84-
bundleId,
85-
appleId,
86-
environment,
87-
true
88-
);
89-
90-
String signingKey = loadSigningKey();
91-
92-
this.appStoreServerAPIClient = new AppStoreServerAPIClient(
93-
signingKey,
94-
keyId,
95-
issuerId,
96-
bundleId,
97-
environment
98-
);
99-
100-
}
101-
102-
@Retryable(
103-
maxAttempts = 3,
104-
backoff = @Backoff(delay = 1000),
105-
retryFor = {APIException.class, IOException.class, VerificationException.class})
106-
public JWSTransactionDecodedPayload getTransactionInfo(String transactionId) throws
107-
APIException,
108-
IOException,
109-
VerificationException {
110-
log.info("Executing GetTransactionInfo for TRANSACTION_ID: {} - Thread: {}",
111-
transactionId, Thread.currentThread().getName());
112-
113-
TransactionInfoResponse transactionInfo = appStoreServerAPIClient.getTransactionInfo(transactionId);
114-
return signedDataVerifier.verifyAndDecodeTransaction(transactionInfo.getSignedTransactionInfo());
115-
}
116-
117-
public VerificationResult verifyAppleTransaction(AppleTransactionVerifyCommand command) {
118-
try {
119-
JWSTransactionDecodedPayload payload = getTransactionInfo(command.getTransactionId());
120-
121-
if (!validateTransaction(payload, command)) {
122-
return VerificationResult.ofVerificationError();
123-
}
124-
125-
return VerificationResult.ofSuccess(payload);
126-
127-
} catch (IOException | APIException e) {
128-
log.warn("❌ Apple transaction verification error. transactionId: {}", command.getTransactionId(), e);
129-
return VerificationResult.ofServerError();
130-
} catch (VerificationException e) {
131-
log.warn("❌ Apple transaction verification error. transactionId: {}", command.getTransactionId(), e);
132-
return VerificationResult.ofVerificationError();
133-
}
134-
}
135-
136-
public void sendConsumptionData(String transactionId, ConsumptionRequest request) {
137-
try {
138-
appStoreServerAPIClient.sendConsumptionData(transactionId, request);
139-
} catch (IOException | APIException e) {
140-
throw new AppleHandler(ErrorStatus.APPLE_VERIFICATION_ERROR);
141-
}
142-
143-
}
144-
145-
public ResponseBodyV2DecodedPayload getNotificationPayload(ResponseBodyV2 responseBody) {
146-
try {
147-
return signedDataVerifier.verifyAndDecodeNotification(
148-
responseBody.getSignedPayload());
149-
} catch (VerificationException e) {
150-
throw new AppleHandler(ErrorStatus.APPLE_VERIFICATION_ERROR);
151-
}
152-
}
153-
154-
public JWSTransactionDecodedPayload getSignedTransactionPayload(
155-
Data data
156-
) {
157-
try {
158-
return signedDataVerifier.verifyAndDecodeTransaction(
159-
data.getSignedTransactionInfo());
160-
} catch (VerificationException e) {
161-
throw new AppleHandler(ErrorStatus.APPLE_VERIFICATION_ERROR);
162-
}
163-
}
164-
165-
private boolean validateTransaction(JWSTransactionDecodedPayload decodedPayload,
166-
AppleTransactionVerifyCommand command) {
167-
// 트랜잭션 아이디가 정상적으로 일치하는 지 여부
168-
if (!decodedPayload.getTransactionId().equals(command.getTransactionId())) {
169-
log.warn("트랜잭션 아이디 불일치. 애플 PAYLOAD : {}, REQUEST 요청 : {}", decodedPayload.getTransactionId(),
170-
command.getTransactionId());
171-
return false;
172-
}
173-
174-
// 1. 환불/취소 여부 확인
175-
if (decodedPayload.getRevocationDate() != null || decodedPayload.getRevocationReason() != null) {
176-
log.warn("트랜잭션이 취소되었습니다. 트랜잭션 ID: {}, 취소 이유: {}",
177-
decodedPayload.getTransactionId(), decodedPayload.getRevocationReason());
178-
return false;
179-
}
180-
181-
// 2. 번들 ID가 앱의 번들 ID와 일치하는지 검증
182-
if (!bundleId.equals(decodedPayload.getBundleId())) {
183-
log.warn("번들 ID 불일치. 예상: {}, 실제: {}",
184-
bundleId, decodedPayload.getBundleId());
185-
return false;
186-
}
187-
188-
// 3. 상품 ID가 요청한 상품과 일치하는지 검증
189-
if (!command.getProductId().equals(decodedPayload.getProductId())) {
190-
log.warn("상품 ID 불일치. 요청: {}, 응답: {}",
191-
command.getProductId(), decodedPayload.getProductId());
192-
return false;
193-
}
194-
195-
// 4. 환경 확인 - 프로덕션에서는 프로덕션, 개발에서는 샌드박스인지 확인
196-
// boolean isProduction = !"Sandbox".equalsIgnoreCase(decodedPayload.getEnvironment());
197-
// if (isProduction) {
198-
// log.warn("환경 불일치. 프로덕션 여부: {}, 프로덕션이어야 함: {}",
199-
// isProduction, shouldBeProduction);
200-
// return false;
201-
// }
202-
203-
// 5. 수량 검증
204-
if (decodedPayload.getQuantity() <= 0) {
205-
log.warn("유효하지 않은 수량: {}", decodedPayload.getQuantity());
206-
return false;
207-
}
208-
209-
// 6. 앱 계정 토큰이 제공된 경우 일치하는지 확인
210-
if (command.getAppAccountToken() != null && decodedPayload.getAppAccountToken() != null &&
211-
!command.getAppAccountToken().equals(decodedPayload.getAppAccountToken())) {
212-
log.warn("앱 계정 토큰 불일치. 요청: {}, 응답: {}",
213-
command.getAppAccountToken(), decodedPayload.getAppAccountToken());
214-
return false;
215-
}
216-
217-
// 7. 모든 검증이 완료되었으므로 true 반환
218-
log.info("Apple IAP Purchase Validation Success. Transaction ID: {}", decodedPayload.getTransactionId());
219-
return true;
220-
}
221-
222-
private Set<InputStream> loadRootCertificates() {
223-
try {
224-
Set<InputStream> certificates = new HashSet<>();
225-
String[] certConfigs = certificateConfigs.split(",");
226-
227-
for (String name : certConfigs) {
228-
String certPath = "certs/" + name.trim();
229-
ClassPathResource resource = new ClassPathResource(certPath);
230-
231-
if (resource.exists()) {
232-
log.info("Loading certificate: {}", certPath);
233-
certificates.add(resource.getInputStream());
234-
} else {
235-
log.warn("Certificate not found: {}", certPath);
236-
}
237-
}
238-
239-
if (certificates.isEmpty()) {
240-
log.error("No certificates were loaded");
241-
throw new RuntimeException("Failed to load any certificates");
242-
}
24313

244-
return certificates;
245-
} catch (Exception e) {
246-
log.error("Error loading root certificates: {}", e.getMessage(), e);
247-
throw new RuntimeException("Failed to load root certificates", e);
248-
}
249-
}
14+
public interface AppleService {
25015

251-
private String loadSigningKey() {
252-
try {
253-
log.info("Loading signing key from: {}", privateKeyPath);
16+
JWSTransactionDecodedPayload getTransactionInfo(String transactionId) throws
17+
APIException, IOException, VerificationException;
25418

255-
ClassPathResource resource = new ClassPathResource(privateKeyPath);
256-
String privateKeyContent;
19+
VerificationResult verifyAppleTransaction(AppleTransactionVerifyCommand command);
25720

258-
try (InputStream inputStream = resource.getInputStream()) {
259-
privateKeyContent = new String(inputStream.readAllBytes());
260-
}
21+
void sendConsumptionData(String transactionId, ConsumptionRequest request);
26122

262-
log.info("Signing key loaded successfully");
263-
return privateKeyContent;
23+
ResponseBodyV2DecodedPayload getNotificationPayload(ResponseBodyV2 responseBody);
26424

265-
} catch (Exception e) {
266-
log.error("Failed to load signing key: {}", e.getMessage(), e);
267-
throw new RuntimeException("Failed to load signing key", e);
268-
}
269-
}
25+
JWSTransactionDecodedPayload getSignedTransactionPayload(Data data);
27026

27127
}

0 commit comments

Comments
 (0)