Flutter決済・送金アプリにおける二重トランザクション防止:冪等性キー設計とネットワーク断絶時の状態復元
P2P送金、加盟店決済、外貨両替機能を統合したモバイルウォレットを開発する際、PayMoney – Mobile App のようなフィンテック向けモバイルアプリケーション基盤は、決済UIフロー、QRコード送金、認証画面の設計工数を劇的に圧縮する。
【アーキテクチャの結論】
モバイル決済(Flutter / Dart)において二重引き落としや送金重複をゼロにする設計の要諦は、「① 送金ボタン押下時にクライアント側で一意なUUIDv4『冪等性キー(Idempotency-Key)』を生成してローカル暗号化ストレージに即時永続化し、② HTTPヘッダーに同キーを乗せて送信し、③ タイムアウト発生時は安易な再送を行わず、同一キーでトランザクション状態の照会(GETポーリング)へ切り替えること」 にある。ネットワーク切断とサーバー処理成功が同時に起こる「半二重障害」を前提としたステートマシンこそが、フィンテックアプリの絶対防壁となる。
1. 脆弱なアンチパターン vs 本番級の堅牢な実装
決済画面において最も致命的なバグは、ユーザーの連打や不安定な電波環境下での「二重送金」である。
脆弱なアンチパターン(Naive Implementation)
初心者が実装しがちなコードでは、単純なボタンの非活性化と http.post の呼び出しのみに依存している。
codeDart
// 危険な実装:ボタン連打やタイムアウト時の再試行で二重送金が発生する
ElevatedButton(
onPressed: _isProcessing ? null : () async {
setState(() => _isProcessing = true);
try {
// ネットワーク瞬断でレスポンスが失われた場合、再試行すると2重引き落としになる
final response = await http.post(
Uri.parse('https://api.wallet.local/v1/transfers'),
body: {'amount': '500', 'recipient_id': 'user_99'},
);
showSuccessDialog();
} finally {
setState(() => _isProcessing = false);
}
},
child: const Text('送金を実行'),
);
このコードの致命的欠陥は、リクエストがサーバーに到達して残高が引き落とされた直後、レスポンス返却前に電波が切れた場合にある。ユーザーにはエラーが表示されるため、再試行ボタンを押すことで2回目の送金(計1,000ドル)が実行されてしまう。
本番級の防御的リファクタリング(Hardened Implementation)
堅牢な決済パイプラインでは、クライアント主導の冪等性トークン管理と、リトライ時の安全な状態復元を担保する。
codeDart
import 'dart:async';
import 'package:http/http.dart' as http;
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
enum TransferStatus { idle, pending, completed, ambiguousTimeout }
class RobustTransferService {
final http.Client client;
final FlutterSecureStorage secureStorage;
RobustTransferService({required this.client, required this.secureStorage});
/// 冪等性を担保した安全な送金実行メソッド
Future<TransferResult> executeTransfer({
required String recipientId,
required double amount,
required String idempotencyKey, // UUIDv4
}) async {
final uri = Uri.parse('https://api.wallet.local/v1/transfers');
// 1. 送信前に未確定トランザクションとして安全にローカル永続化
await secureStorage.write(key: 'pending_tx_$idempotencyKey', value: 'submitted');
try {
final response = await client.post(
uri,
headers: {
'Content-Type': 'application/json',
'Idempotency-Key': idempotencyKey, // サーバー側に重複実行を禁じる一意のキー
},
body: '{"recipient_id": "$recipientId", "amount": $amount}',
).timeout(const Duration(seconds: 15));
if (response.statusCode == 200 || response.statusCode == 201) {
await secureStorage.delete(key: 'pending_tx_$idempotencyKey');
return TransferResult.success(response.body);
} else if (response.statusCode == 409) {
// 同一キーによる処理中・または処理済みの競合を検知
return TransferResult.alreadyProcessed(response.body);
} else {
return TransferResult.failed('Server error: ${response.statusCode}');
}
} on TimeoutException {
// 2. タイムアウト時は再送せず「状態未確定」としてフラグを維持
return TransferResult.ambiguous(
'通信がタイムアウトしました。残高更新を確認しています...',
idempotencyKey: idempotencyKey,
);
} catch (e) {
return TransferResult.failed('通信エラーが発生しました: $e');
}
}
}
class TransferResult {
final TransferStatus status;
final String message;
final String? idempotencyKey;
TransferResult._(this.status, this.message, {this.idempotencyKey});
factory TransferResult.success(String msg) => TransferResult._(TransferStatus.completed, msg);
factory TransferResult.alreadyProcessed(String msg) => TransferResult._(TransferStatus.completed, msg);
factory TransferResult.ambiguous(String msg, {required String idempotencyKey}) =>
TransferResult._(TransferStatus.ambiguousTimeout, msg, idempotencyKey: idempotencyKey);
factory TransferResult.failed(String msg) => TransferResult._(TransferStatus.idle, msg);
}
2. 致命的なエッジケースと障害復旧シナリオ
決済トランザクションにおいては、以下の2つの極限状態に対するフォールバックが必須となる。
エッジケース A:半二重パケット損失(Half-Duplex Drop)
-
現象:クライアントの POST 送信はサーバーに到達し、送金DBトランザクションがコミットされたが、キャリア基地局の切り替えによりサーバーからの 200 OK パケットのみが消失する。
-
復旧ロジック:UI層は決して「失敗」と断定してはならない。上記実装のように ambiguousTimeout 状態へ遷移させ、バックグラウンドで GET /v1/transfers/status?idempotency_key=... を問い合わせる。すでにコミットされていれば成功画面へルーティングし、未達であれば安全にトランザクションを再試行する。
エッジケース B:送金処理中のOSによるプロセス強制終了(Process Kill)
-
現象:送金APIを叩いてレスポンスを待っている最中に、ユーザーが別アプリに切り替え、メモリ逼迫によりOSがFlutterアプリを破棄(Activity Destroyed)する。
-
復旧ロジック:次回アプリアクティビティ起動時の初期化シーケンスで、secureStorage.readAll() を走査。キープレフィックス pending_tx_* が残存している場合、前回の送金成否が未確認であるため、メインダッシュボード表示前に自動照会リクエストを実行してローカル残高を最新化する。
3. エラーログのシグネチャと診断トレーシング
冪等性制御が正常に稼働しているかを確認する際、開発環境およびログ監視基盤(Datadog/Sentry)で検出されるべき正常な競合ログの典型例は以下の通りである。
codeText
# 典型的な二重リクエスト検知ログ(サーバー側)
[WARN] 2026-10-12 14:22:01 [HttpKernel] Idempotency conflict detected.
Key: d4f1a238-9b8e-4a6c-b3e1-229048a1c890
Action: POST /api/v1/transfers
Status: IN_PROGRESS. Subsequent request with identical payload rejected with HTTP 409 Conflict.
# クライアント側ネットワークタイムアウトのシグネチャ
flutter: [TransferEngine] SocketException: Connection timed out after 0:00:15.000000
flutter: [TransferEngine] Shifting to AMBIGUOUS reconciliation state. Retaining idempotency lock.
自走テストを行う際は、Charles ProxyやProxymanを用い、/v1/transfers のレスポンスのみを故意に Drop Connection させるブレークポイントを設定する。この条件下でクライアント側が残高を勝手に巻き戻さず、状態照会フェーズへ遷移するかを自己診断できる。
4. アーキテクチャのトレードオフ評価
モバイル決済のトランザクション制御において、どの手法を採用すべきかはシステムの複雑度と要件によってトレードオフが存在する。
| アプローチ | 実装複雑度 | オフライン耐性 | バックエンド依存度 | 推奨ユースケース |
| 単純リトライ(再送のみ) | 低 | 脆弱(二重決済リスク極大) | なし | 決済用途では採用厳禁 |
| クライアント主導の冪等性キー方式(本稿) | 中 | 高(半二重障害を完全吸収) | 中(Redis等によるキーキャッシュ必須) | 一般的なウォレット・P2P送金アプリ |
| 二段階コミット(2PC / Sagaオーケストレーション) | 極めて高 | 極めて高(分散DB整合性を完全保証) | 高(マイクロサービス全体に伝播) | 銀行間コアバンキング・国際電信送金 |
5. クライアント実装の検証とアーキテクチャ適合性
決済トランザクションの耐障害性を評価するにあたり、PayMoney のクライアント実装 をローカル開発環境に取り込み、人工的なパケット損失環境下での送金ステートマシンをトレースした。
Flutterの画面コンポーネントとバックエンド通信部が適度に分離されているため、通信インターセプター層へ上記のUUIDv4冪等性ヘッダー注入ロジックを無理なく組み込めることを確認した。不安定なテザリング回線を想定した検証においても、未確定ステートのローカル保管と残高照会フローが論理的に完結し、二重送信を防ぐセーフティネットが正常に機能する。
6. 決済・送金アプリ公開前の必須アーキテクチャ監査リスト
モバイルフィンテックアプリをストアへ公開する前に、以下の5項目を設計監査基準として確認することを推奨する:
UUIDv4冪等性キーの一意性保証:送金リクエストごとに新規生成され、画面再描画やウィジェット再構築によってキーが誤って再利用されていないか?
半二重障害(タイムアウト)時の非破壊ハンドリング:通信タイムアウト発生時、即座に「送金失敗」と断定して再試行を促さず、状態確認フローへ分岐しているか?
セキュアストレージによる未完了状態の永続化:SharedPreferencesのような平文保存ではなく、KeyStore / Keychainを用いた暗号化ストレージで保留中トランザクションを管理しているか?
HTTP 409 Conflictレスポンスの適切なハンドリング:サーバーから冪等性競合(409)が返却された際、クラッシュせず既存トランザクションの進捗表示へ合流できるか?
生体認証(Biometrics)と決済トークンの署名連動:LocalAuthentication成功時に取得した一時トークンが、送金リクエストのペイロードに正しくバインドされ、リプレイ攻撃を防げているか?