앱 공통 계층 — 데이터 저장소 · 서버 시계 · 푸시 · 결제·광고 · 다국어 5종 · 행성 표현 · 공통 UI · 화면 자산

This commit is contained in:
Sn-Kinos
2026-09-29 17:06:48 +09:00
parent cbfb72efe0
commit c4e9a9430e
165 changed files with 29991 additions and 54 deletions
@@ -0,0 +1,93 @@
import 'dart:async';
import 'dart:io' show Platform;
import 'package:google_mobile_ads/google_mobile_ads.dart';
import '../data/backend.dart';
import '../i18n/app_locale.dart';
/// 광고 단위 ID — **실 값으로 바꾸는 곳은 여기 한 곳이다**(앱 ID 는 AndroidManifest ·
/// Info.plist 의 `GADApplicationIdentifier`).
///
/// 지금 값은 Google 이 공개한 **테스트 광고 단위**다(별첨 1 ⑥ 9주차 샌드박스). 테스트
/// 단위는 실 계정 없이 항상 테스트 광고를 채우고 수익·무효 트래픽 판정이 없다.
abstract final class AdUnits {
static const androidRewarded = 'ca-app-pub-3940256099942544/5224354917';
static const iosRewarded = 'ca-app-pub-3940256099942544/1712485313';
static String get rewarded =>
Platform.isIOS ? iosRewarded : androidRewarded;
}
/// 불러 둔 보상형 광고 한 편.
///
/// 광고 네트워크 경계다. 미디에이션(AppLovin MAX 등)을 붙일 때 이 뒤만 바꾼다.
abstract interface class RewardedAdHandle {
/// 전면으로 띄우고 닫힐 때까지 기다린다. 끝까지 봐서 보상 콜백이 왔으면 노출 ID,
/// 중간에 닫았거나 띄우지 못했으면 null. 끝나면 스스로 정리한다.
Future<String?> show();
/// 띄우지 않고 버린다(사용자가 로딩 중 건너뛴 경우).
void dispose();
}
/// Google Mobile Ads 보상형 광고를 불러온다.
///
/// 불러오지 못하면(채울 광고 없음 · 네트워크 · 시간 초과) `E-AD-01` 로 끊는다 —
/// 원장 B-03 「광고 로드 실패 시 기본 보상은 지급하고 추가 보상·횟수는 차감하지 않음」.
Future<RewardedAdHandle> loadGoogleRewardedAd({
Duration timeout = const Duration(seconds: 15),
}) async {
await (_initialized ??= MobileAds.instance.initialize());
final loaded = Completer<RewardedAd>();
await RewardedAd.load(
adUnitId: AdUnits.rewarded,
request: const AdRequest(),
rewardedAdLoadCallback: RewardedAdLoadCallback(
onAdLoaded: loaded.complete,
onAdFailedToLoad: (error) => loaded.completeError(error),
),
);
try {
return _GoogleRewardedAd(await loaded.future.timeout(timeout));
} catch (_) {
// 시간 초과 뒤에 늦게 도착한 광고는 띄우지 않고 버린다.
unawaited(loaded.future.then((ad) => ad.dispose(), onError: (_) {}));
throw BackendException('E-AD-01', appL10n.adLoadFailed);
}
}
Future<InitializationStatus>? _initialized;
class _GoogleRewardedAd implements RewardedAdHandle {
_GoogleRewardedAd(this._ad);
final RewardedAd _ad;
@override
Future<String?> show() async {
final closed = Completer<void>();
String? impression;
_ad.fullScreenContentCallback = FullScreenContentCallback(
onAdDismissedFullScreenContent: (_) => closed.complete(),
onAdFailedToShowFullScreenContent: (_, _) => closed.complete(),
);
try {
await _ad.show(
onUserEarnedReward: (_, _) {
// 서버가 이 값으로 같은 노출의 이중 지급을 막는다(`adRewardLogs/{id}`).
impression = _ad.responseInfo?.responseId ??
'gma-${DateTime.now().microsecondsSinceEpoch}';
},
);
await closed.future;
} finally {
// 띄우기가 던져도 네이티브 광고 객체를 남기지 않는다.
await _ad.dispose();
}
return impression;
}
@override
void dispose() => unawaited(_ad.dispose());
}
+145
View File
@@ -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);
}
}
}