앱 공통 계층 — 데이터 저장소 · 서버 시계 · 푸시 · 결제·광고 · 다국어 5종 · 행성 표현 · 공통 UI · 화면 자산
This commit is contained in:
@@ -0,0 +1,145 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io' show Platform;
|
||||
|
||||
import 'package:in_app_purchase/in_app_purchase.dart';
|
||||
import 'package:in_app_purchase_android/in_app_purchase_android.dart';
|
||||
|
||||
import '../data/backend.dart';
|
||||
import '../i18n/app_locale.dart';
|
||||
|
||||
/// 인앱 결제 — 별첨 1 ⑥ 「영수증 서버검증 통과(위조 차단)」 · 정책 원장 B-02 · B-04 · B-84.
|
||||
///
|
||||
/// **앱은 지급하지 않는다.** 스토어가 준 영수증(Android 구매 토큰 · iOS 영수증)을
|
||||
/// `fnIapVerify` 에 넘기고, 서버가 상품 ID 로 혜택을 정한다. 서버가 받아들인 뒤에만
|
||||
/// 스토어에 구매 완료를 알린다 — 검증 전에 완료하면 검증이 실패해도 결제가 확정되고,
|
||||
/// 완료하지 않은 Android 구매는 3일 뒤 스토어가 자동 환불한다.
|
||||
///
|
||||
/// 상품 ID 는 서버 상품 표(`firebase/functions/src/payment/receipt.ts`)와 Hero Gift
|
||||
/// Catalog(`heroGiftProducts/{스토어 상품 ID}`)가 정한다. 스토어에 상품이 아직 없으면
|
||||
/// 조회가 비어 오고, 화면은 「스토어 상품 준비 중」으로 정직하게 멈춘다.
|
||||
class Store {
|
||||
Store(this._backend, {InAppPurchase? iap}) : _iap = iap ?? InAppPurchase.instance;
|
||||
|
||||
/// 실 모드에서만 채워진다(`Repositories`). 목 모드와 테스트에서는 null 이라
|
||||
/// 화면이 「준비 중」을 띄운다.
|
||||
static Store? instance;
|
||||
|
||||
/// Focus 월 구독 — 서버 `SUBSCRIPTION_PRODUCTS` 와 같은 ID.
|
||||
static const focusMonthly = 'focus.monthly';
|
||||
static const _subscriptions = {'focus.monthly', 'focus.yearly'};
|
||||
|
||||
static String get notReady => appL10n.storeNotReady;
|
||||
|
||||
final Backend _backend;
|
||||
final InAppPurchase _iap;
|
||||
final _products = <String, ProductDetails>{};
|
||||
final _messages = StreamController<String>.broadcast();
|
||||
StreamSubscription<List<PurchaseDetails>>? _purchases;
|
||||
|
||||
/// 사용자에게 보여 줄 결제 결과 한 줄. 결제는 OS 창에서 끝나 화면 밖에서 도착한다.
|
||||
Stream<String> get messages => _messages.stream;
|
||||
|
||||
/// 구매 알림을 듣기 시작한다. 로그인 뒤에 한 번 부른다.
|
||||
///
|
||||
/// iOS 는 완료하지 않은 거래를 구독 즉시 다시 보낸다. Android 는 보내지 않으므로
|
||||
/// 복원으로 끌어온다 — 결제 대기(pending)였다가 승인된 구매와 검증에 실패했던
|
||||
/// 구매가 여기서 다시 서버로 간다. 서버는 거래 ID 로 멱등이라 여러 번 가도 한 번 지급이다.
|
||||
void start() {
|
||||
if (_purchases != null) return;
|
||||
_purchases = _iap.purchaseStream.listen(_onPurchases, onError: (_) {});
|
||||
if (Platform.isAndroid) unawaited(_iap.restorePurchases().catchError((_) {}));
|
||||
}
|
||||
|
||||
Future<void> dispose() async {
|
||||
await _purchases?.cancel();
|
||||
_purchases = null;
|
||||
}
|
||||
|
||||
/// 스토어 상품 한 개. 스토어를 쓸 수 없거나 등록되지 않았으면 null.
|
||||
Future<ProductDetails?> product(String id) async {
|
||||
final cached = _products[id];
|
||||
if (cached != null) return cached;
|
||||
if (!await _iap.isAvailable()) return null;
|
||||
final result = await _iap.queryProductDetails({id});
|
||||
if (result.productDetails.isEmpty) return null;
|
||||
return _products[id] = result.productDetails.first;
|
||||
}
|
||||
|
||||
/// 결제 창을 띄운다. 결과는 [messages] 로 온다.
|
||||
Future<void> buy(String productId) async {
|
||||
final details = await product(productId);
|
||||
if (details == null) throw BackendException('E-PAY-02', notReady);
|
||||
final param = PurchaseParam(productDetails: details);
|
||||
if (_subscriptions.contains(productId)) {
|
||||
await _iap.buyNonConsumable(purchaseParam: param);
|
||||
} else {
|
||||
// 자동 소비를 끈다 — 서버 검증 전에 소비하면 실패한 구매를 다시 보낼 수 없다.
|
||||
await _iap.buyConsumable(purchaseParam: param, autoConsume: false);
|
||||
}
|
||||
}
|
||||
|
||||
/// 구매 복원. Focus 구독을 다른 기기·재설치 뒤에 되살린다.
|
||||
Future<void> restore() => _iap.restorePurchases();
|
||||
|
||||
Future<void> _onPurchases(List<PurchaseDetails> purchases) async {
|
||||
for (final purchase in purchases) {
|
||||
switch (purchase.status) {
|
||||
case PurchaseStatus.pending:
|
||||
_messages.add(appL10n.storePending);
|
||||
case PurchaseStatus.canceled:
|
||||
case PurchaseStatus.error:
|
||||
if (purchase.status == PurchaseStatus.error) {
|
||||
_messages.add(appL10n.storeFailed);
|
||||
}
|
||||
// 실패·취소 거래도 닫아야 iOS 큐에 남지 않는다. 결제가 확정되지 않은 건이다.
|
||||
if (purchase.pendingCompletePurchase) {
|
||||
await _iap.completePurchase(purchase);
|
||||
}
|
||||
case PurchaseStatus.purchased:
|
||||
case PurchaseStatus.restored:
|
||||
await _verify(purchase);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _verify(PurchaseDetails purchase) async {
|
||||
final details = _products[purchase.productID];
|
||||
try {
|
||||
await _backend.call('fnIapVerify', {
|
||||
'platform': Platform.isIOS ? 'ios' : 'android',
|
||||
'receipt': purchase.verificationData.serverVerificationData,
|
||||
'productId': purchase.productID,
|
||||
// iOS 앱 영수증은 여러 거래를 담는다 — 서버가 이 거래를 고른다.
|
||||
if (purchase.purchaseID case final id?) 'transactionId': id,
|
||||
// 표시용. 서버는 지급 판단에 쓰지 않는다.
|
||||
if (details != null) ...{
|
||||
'title': details.title,
|
||||
'priceAmount': details.rawPrice,
|
||||
'currency': details.currencyCode,
|
||||
},
|
||||
});
|
||||
} on BackendException catch (error) {
|
||||
if (purchase.status == PurchaseStatus.purchased) _messages.add(error.message);
|
||||
if (error.code == 'E-PAY-01' && !Platform.isAndroid) {
|
||||
// 재시도해도 같은 거부(판매 중지 · 다른 계정 · 소속 행성 없음). iOS 는 거래를 닫지
|
||||
// 않으면 실행마다 다시 온다. 환불은 Apple 경로(사용자 요청)로 간다.
|
||||
if (purchase.pendingCompletePurchase) await _iap.completePurchase(purchase);
|
||||
}
|
||||
// 그 밖은 닫지 않는다 — 다음 실행에서 다시 보낸다. Android 는 E-PAY-01 도 확인하지
|
||||
// 않고 둔다: 3일 안에 확인되지 않은 구매는 스토어가 **자동 환불**한다. 다시 보내는
|
||||
// 복원 재전송은 조용하므로 알림은 처음 한 번뿐이다.
|
||||
return;
|
||||
}
|
||||
if (Platform.isAndroid && !_subscriptions.contains(purchase.productID)) {
|
||||
// 소비해야 같은 상품을 다시 살 수 있다. 소비가 곧 확인(acknowledge)이다.
|
||||
await _iap
|
||||
.getPlatformAddition<InAppPurchaseAndroidPlatformAddition>()
|
||||
.consumePurchase(purchase);
|
||||
} else if (purchase.pendingCompletePurchase) {
|
||||
await _iap.completePurchase(purchase);
|
||||
}
|
||||
if (purchase.status == PurchaseStatus.purchased) {
|
||||
_messages.add(appL10n.storePurchased);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user