Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
.PHONY: test re-pub build-apk build-ios build-prd

# Test Target
test:
flutter test -j 5

# Re-pub Target
re-pub:
flutter clean
flutter pub get

# Build APK Target
build-apk:
flutter build apk

# Build iOS Target
build-ios:
flutter build ios

# Build Production (App Bundle) Target
build-prd:
flutter clean
flutter pub get
flutter build appbundle --release
41 changes: 41 additions & 0 deletions docs/step27-2/spec.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
# 階段 27-2|IAP 流程協調器(Orchestrator)

## 目標

在 `PurchaseService` 之上新增一層 **PurchaseOrchestrator**,集中處理:
查價快取、購買流程、驗證 API 呼叫(先串 mock endpoint)、權益發放呼叫、冪等。

### 規格

* 介面(無需程式碼,只定義行為)
* `preloadCatalogPrices(List<String> storeIds)`
* 轉 `skuId` 後統一呼叫 `PurchaseService.queryProducts`
* 緩存結果,存活至 app 關閉或手動清除
* `purchase(String storeId)`
* 取得 `skuId` → 呼叫 `PurchaseService.buy`
* 監聽 `purchaseStream` 成功事件 → 呼叫「驗證 API」(mock endpoint URL 由 Config 注入)
* `ok=true` 時才呼叫 **EntitlementManager.grant**(階段 27-3)
* 冪等(見 階段 27-3)
* `restoreNonConsumables()`
* 代理 `PurchaseService.restore` 行為
* 對於回來的 event:只針對 **non-consumable** 嘗試套用權益(階段 27-3 冪等保護)
* Orchestrator 必須發出 UI 可用的狀態事件(Stream):
* `loading`, `purchasing(productId)`, `verifying(orderId?)`, `success(storeId)`, `error(code,message)`

## 驗收實例化需求

1. **查價快取**
* Given:同一批 `storeIds` 連續呼叫 `preloadCatalogPrices` 兩次
* Then:第二次不得再次呼叫底層 `PurchaseService.queryProducts`
2. **購買成功流程**
* Given:`store.card_click_perm`
* When:`purchase` → Mock 購買成功 → Mock 驗證 API `ok=true`
* Then:觸發 `EntitlementManager.grant('card_click_perm')`,Orchestrator 推播 `success`
3. **購買失敗(驗證失敗)**
* Given:`store.card_click_perm`
* When:`purchase` → Mock 購買成功 → Mock 驗證回 `ok=false, reason='sku_not_allowed'`
* Then:不呼叫 `grant`、Orchestrator 推播 `error('verify_failed')`
4. **恢復流程(non-consumable)**
* Given:`restoreNonConsumables()`
* When:`PurchaseService.restore` 推回 `card_click_perm` 成功事件
* Then:呼叫 `grant('card_click_perm')` 並推播 `success`(冪等保護由 階段 27-3)
23 changes: 23 additions & 0 deletions lib/services/entitlement_manager.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import 'package:flutter/foundation.dart';

/// 權益管理器介面
///
/// 實際授予玩家購買後的權益(例如解鎖功能、加值道具)。
/// 冪等性建議在實作層處理:重複授權同一權益不應造成副作用。
abstract class EntitlementManager {
Future<void> grant(String entitlementKey);
}

/// 簡易的記憶體 Mock 版本(測試用)
class MockEntitlementManager implements EntitlementManager {
final List<String> granted = <String>[];

@override
Future<void> grant(String entitlementKey) async {
// 冪等保護:避免重複插入
if (!granted.contains(entitlementKey)) {
granted.add(entitlementKey);
debugPrint('[Entitlement] grant => $entitlementKey');
}
}
}
183 changes: 183 additions & 0 deletions lib/services/purchase_orchestrator.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
import 'dart:async';

import '../models/purchase_models.dart';
import 'config_service.dart';
import 'entitlement_manager.dart';
import 'purchase_service.dart';
import 'verify_client.dart';

/// Orchestrator 狀態
class OrchestratorState {
final String type; // loading | purchasing | verifying | success | error
final String? productId;
final String? orderId;
final String? code; // verify_failed 等
final String? message;

const OrchestratorState._(
{required this.type,
this.productId,
this.orderId,
this.code,
this.message});

factory OrchestratorState.loading() => const OrchestratorState._(type: 'loading');
factory OrchestratorState.purchasing(String productId) =>
OrchestratorState._(type: 'purchasing', productId: productId);
factory OrchestratorState.verifying({String? orderId}) =>
OrchestratorState._(type: 'verifying', orderId: orderId);
factory OrchestratorState.success(String productId) =>
OrchestratorState._(type: 'success', productId: productId);
factory OrchestratorState.error(String code, String message) =>
OrchestratorState._(type: 'error', code: code, message: message);
}

/// IAP 流程協調器(Orchestrator)
class PurchaseOrchestrator {
final ConfigService _configService;
final PurchaseService _purchaseService;
final VerifyClient _verifyClient;
final EntitlementManager _entitlementManager;

final StreamController<OrchestratorState> _stateCtr =
StreamController<OrchestratorState>.broadcast(sync: true);
StreamSubscription<PurchaseEvent>? _purchaseSub;

// 以 productId 為 key 的商品資訊快取
final Map<String, ProductInfo> _priceCache = <String, ProductInfo>{};

// 恢復模式旗標:僅在 restoreNonConsumables 時為 true
bool _restoring = false;

bool _disposed = false;

Stream<OrchestratorState> get stateStream => _stateCtr.stream;

PurchaseOrchestrator({
required ConfigService configService,
required PurchaseService purchaseService,
required VerifyClient verifyClient,
required EntitlementManager entitlementManager,
}) : _configService = configService,
_purchaseService = purchaseService,
_verifyClient = verifyClient,
_entitlementManager = entitlementManager {
// 監聽底層購買事件
_purchaseSub = _purchaseService.purchaseStream.listen(_onPurchaseEvent);
}

/// 將 storeId 轉為 skuId(目前先 1:1 映射;若未來 config 有 sku_id 欄位再調整)
String _toSkuId(String storeId) {
return storeId; // MVP: 直接使用同一 ID
}

/// 查價快取:對已存在於快取的商品不重複查詢
Future<List<ProductInfo>> preloadCatalogPrices(List<String> storeIds) async {
final ids = storeIds.map(_toSkuId).toList();
final missing = <String>[];
for (final id in ids) {
if (!_priceCache.containsKey(id)) missing.add(id);
}
if (missing.isNotEmpty) {
final list = await _purchaseService.queryProducts(missing);
for (final p in list) {
_priceCache[p.id] = p;
}
}
// 回傳所請求的商品資訊(來自快取或新查詢)
return ids.map((id) => _priceCache[id]).whereType<ProductInfo>().toList();
}

/// 觸發購買流程
Future<void> purchase(String storeId) async {
if (_disposed) return;
final skuId = _toSkuId(storeId);
_stateCtr.add(OrchestratorState.purchasing(skuId));
await _purchaseService.buy(skuId);
// 後續由 _onPurchaseEvent 處理
}

/// 恢復購買(iOS 必備)。僅針對 non-consumable 進行權益套用。
Future<void> restoreNonConsumables() async {
if (_disposed) return;
_restoring = true;
try {
await _purchaseService.restore();
} finally {
// 讓 onEvent 能在本輪事件流處理完之前仍視為 restoring
// 在第一個成功事件處理後關閉 restoring
// 這裡不立即還原旗標,交由事件處理端決定
}
}

Future<void> _onPurchaseEvent(PurchaseEvent event) async {
if (_disposed) return;

switch (event.status) {
case PurchaseStatus.success:
if (_restoring) {
// 僅針對 non-consumable 嘗試權益發放
if (_isNonConsumable(event.productId)) {
final entitlement = _entitlementKeyFor(event.productId);
await _entitlementManager.grant(entitlement);
_stateCtr.add(OrchestratorState.success(event.productId));
}
// 單輪恢復後即退出恢復模式(若有多筆也逐筆處理)
_restoring = false;
} else {
// 正常購買流程:需進行驗證
_stateCtr.add(OrchestratorState.verifying(orderId: null));
try {
// 目前沒有實際 orderId,先給一個 placeholder
final verify = await _verifyClient.verify(
skuId: event.productId,
orderId: 'mock-order',
);
if (verify.ok) {
final entitlement = _entitlementKeyFor(event.productId);
await _entitlementManager.grant(entitlement);
_stateCtr.add(OrchestratorState.success(event.productId));
} else {
_stateCtr.add(OrchestratorState.error('verify_failed',
verify.reason ?? 'verify not ok'));
}
} catch (e) {
_stateCtr.add(OrchestratorState.error('verify_exception', '$e'));
}
}
break;
case PurchaseStatus.pending:
_stateCtr.add(OrchestratorState.loading());
break;
case PurchaseStatus.canceled:
_stateCtr.add(OrchestratorState.error('canceled', event.message ?? ''));
break;
case PurchaseStatus.error:
_stateCtr.add(OrchestratorState.error('purchase_error', event.message ?? ''));
break;
}
}

bool _isNonConsumable(String productId) {
final store = _configService.getStoreConfig();
final cfg = store[productId] as Map<String, dynamic>?;
if (cfg == null) return false;
final type = cfg['purchase_limit_type'] as String?;
final max = cfg['purchase_max_count'] as int?;
return type == 'limited' && (max == null || max == 1);
}

String _entitlementKeyFor(String productId) {
// 規格案例:store.card_click_perm => grant('card_click_perm')
if (productId.startsWith('store.')) {
return productId.substring('store.'.length);
}
return productId;
}

void dispose() {
_disposed = true;
_purchaseSub?.cancel();
_stateCtr.close();
}
}
38 changes: 38 additions & 0 deletions lib/services/verify_client.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
/// 驗證 API 客戶端介面與簡易 Mock
///
/// 真實情境會呼叫後端伺服器驗證收據/訂單,這裡先提供可注入的抽象層。
class VerifyClient {
final String? endpoint; // 可由 Config 注入,測試可為 null

VerifyClient({this.endpoint});

/// 送出驗證請求
/// 回傳 ok=true 才視為驗證通過
Future<VerifyResult> verify({
required String skuId,
required String orderId,
}) async {
// 預設為不實作:交由子類或測試替身處理
throw UnimplementedError('VerifyClient.verify not implemented');
}
}

class VerifyResult {
final bool ok;
final String? reason; // 例如 sku_not_allowed 等

const VerifyResult({required this.ok, this.reason});
}

/// 測試/開發用的 Mock 驗證客戶端
class MockVerifyClient extends VerifyClient {
VerifyResult next = const VerifyResult(ok: true);

MockVerifyClient({super.endpoint});

@override
Future<VerifyResult> verify({required String skuId, required String orderId}) async {
// 直接回傳預設結果
return next;
}
}
Loading