|
1 | 1 | package umc.th.juinjang.api.apple.service; |
2 | 2 |
|
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 | | - |
15 | 3 | import com.apple.itunes.storekit.client.APIException; |
16 | | -import com.apple.itunes.storekit.client.AppStoreServerAPIClient; |
17 | 4 | import com.apple.itunes.storekit.model.ConsumptionRequest; |
18 | 5 | import com.apple.itunes.storekit.model.Data; |
19 | | -import com.apple.itunes.storekit.model.Environment; |
20 | 6 | import com.apple.itunes.storekit.model.JWSTransactionDecodedPayload; |
21 | 7 | import com.apple.itunes.storekit.model.ResponseBodyV2; |
22 | 8 | import com.apple.itunes.storekit.model.ResponseBodyV2DecodedPayload; |
23 | | -import com.apple.itunes.storekit.model.TransactionInfoResponse; |
24 | | -import com.apple.itunes.storekit.verification.SignedDataVerifier; |
25 | 9 | 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; |
30 | 11 | import umc.th.juinjang.api.apple.service.command.AppleTransactionVerifyCommand; |
31 | | -import umc.th.juinjang.api.pencil.service.PencilQueryService; |
32 | 12 | 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 | | - } |
243 | 13 |
|
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 { |
250 | 15 |
|
251 | | - private String loadSigningKey() { |
252 | | - try { |
253 | | - log.info("Loading signing key from: {}", privateKeyPath); |
| 16 | + JWSTransactionDecodedPayload getTransactionInfo(String transactionId) throws |
| 17 | + APIException, IOException, VerificationException; |
254 | 18 |
|
255 | | - ClassPathResource resource = new ClassPathResource(privateKeyPath); |
256 | | - String privateKeyContent; |
| 19 | + VerificationResult verifyAppleTransaction(AppleTransactionVerifyCommand command); |
257 | 20 |
|
258 | | - try (InputStream inputStream = resource.getInputStream()) { |
259 | | - privateKeyContent = new String(inputStream.readAllBytes()); |
260 | | - } |
| 21 | + void sendConsumptionData(String transactionId, ConsumptionRequest request); |
261 | 22 |
|
262 | | - log.info("Signing key loaded successfully"); |
263 | | - return privateKeyContent; |
| 23 | + ResponseBodyV2DecodedPayload getNotificationPayload(ResponseBodyV2 responseBody); |
264 | 24 |
|
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); |
270 | 26 |
|
271 | 27 | } |
0 commit comments