# Flutter Google Play 充值对接 本文描述 Android App 接入 Google Play 一次性金币充值的 Flutter 调用流程。Flutter 只负责购买流程和提交 Google purchase token;订单校验、到账金额、入账、幂等和消费确认都以后端 `wallet-service` 为准。 所有 gateway HTTP 接口都返回统一外壳: ```json { "code": "OK", "message": "ok", "request_id": "server-generated-request-id", "data": {} } ``` Flutter 只有在 `code == "OK"` 时读取 `data`。其他错误必须记录 `request_id`,方便后端排查。 ## 1. 当前实现状态 已实现: - `GET /api/v1/wallet/recharge/products`:App 端充值商品列表。 - `POST /api/v1/wallet/payments/google/confirm`:提交 Google purchase token,后端调用 Google Play Developer API 校验、入账、写支付订单审计并 consume。 - `GET /api/v1/wallet/me/balances`:刷新金币余额。 - 钱包余额 IM 通知:`WalletBalanceChanged`。 - `payment_orders`:保存 Google provider 订单审计,purchase token 只落 SHA256,不落明文。 后端待补齐: - Google Play RTDN / Pub/Sub 回调:只做补偿通知,不替代客户端确认接口。 Flutter 可以按本文契约联调商品列表、本地 Google Billing 流程和确认支付接口。 ## 2. Flutter 依赖 建议使用 Flutter 官方维护的 `in_app_purchase` 插件接 Google Play Billing。 `pubspec.yaml`: ```yaml dependencies: in_app_purchase: ``` Android 真机联调要求: - App 使用正式包名和签名,包名必须与 Play Console App 一致。 - 测试账号加入 Play Console license testing 或内部测试轨道。 - Play Console 中的一次性商品必须激活,并覆盖测试账号所在地区。 ## 3. 获取充值商品列表 `GET /api/v1/wallet/recharge/products` 登录接口,需要 `Authorization: Bearer `。gateway 会根据当前登录用户资料里的 `region_id` 过滤商品;Flutter 不传用户 ID 和区域 ID。 请求头: | Header | 必填 | 说明 | | --- | --- | --- | | `Authorization` | 是 | App 登录 access token。 | | `X-App-Code` | 否 | App 编码,默认 `lalu`。 | | `X-App-Platform` | 是 | 固定传 `android`。也可以用 query `platform=android` 覆盖。 | 示例: ```http GET /api/v1/wallet/recharge/products Authorization: Bearer X-App-Code: lalu X-App-Platform: android ``` 成功响应: ```json { "code": "OK", "message": "ok", "request_id": "req_abc", "data": { "channels": ["google"], "products": [ { "product_id": 11, "product_code": "iap_android_11", "product_name": "1500 Coins", "description": "coin pack", "platform": "android", "channel": "google", "currency_code": "USDT", "coin_amount": 1500, "amount_minor": 1500000, "amount_micro": 1500000, "policy_version": "", "region_ids": [2002], "resource_asset_type": "COIN", "status": "active", "enabled": true, "sort_order": 0 } ] } } ``` 字段规则: | 字段 | Flutter 用途 | | --- | --- | | `product_id` | 后端本地商品 ID,提交确认支付时原样带回。 | | `product_code` | 当前作为 Google Play productId 使用。必须与 Play Console 商品 ID 完全一致。 | | `channel` | Android Google 支付固定为 `google`。 | | `coin_amount` | 到账金币数,展示和到账都以后端为准。 | | `amount_micro` / `amount_minor` | 后台配置金额,仅用于后台审计;Google 支付价格展示必须用 Google `ProductDetails`。 | | `resource_asset_type` | 当前固定为 `COIN`。 | | `enabled` / `status` | 只展示 `enabled=true && status=active` 的商品。 | 注意:当前后端 `product_code` 是自动生成值,例如 `iap_android_11`。如果 Play Console 已经创建了其他商品 ID,例如 `coins_150000_usd499`,后端需要先新增 `google_product_id` 或 `provider_product_id` 字段,Flutter 不要自行硬编码映射。 ## 4. 查询 Google ProductDetails Flutter 拿到后端商品列表后,只对 `channel=google` 且启用的商品查询 Google Play: ```dart final Set googleProductIds = backendProducts .where((item) => item.channel == 'google' && item.enabled) .map((item) => item.productCode) .where((value) => value.isNotEmpty) .toSet(); final ProductDetailsResponse response = await InAppPurchase.instance.queryProductDetails(googleProductIds); ``` 展示合并规则: - 金币数量用后端 `coin_amount`。 - 商品是否展示以后端上架状态和 Google 查询结果共同决定。 - 价格文案用 Google `ProductDetails.price`,不要用后端 `amount_micro`。 - 如果后端商品存在但 Google 查不到 `ProductDetails`,该商品不展示,并记录客户端日志。 合并后的本地模型建议保留: ```dart class GoogleRechargeSku { GoogleRechargeSku({ required this.productId, required this.googleProductId, required this.coinAmount, required this.productDetails, }); final int productId; final String googleProductId; final int coinAmount; final ProductDetails productDetails; } ``` ## 5. 发起购买 金币充值是 consumable。为了避免客户端先消费导致后端未入账,Android 不要自动 consume;后端校验并入账后由后端调用 Google consume。 ```dart final PurchaseParam purchaseParam = PurchaseParam( productDetails: sku.productDetails, ); await InAppPurchase.instance.buyConsumable( purchaseParam: purchaseParam, autoConsume: false, ); ``` Flutter 必须监听购买流: ```dart late final StreamSubscription> _purchaseSub; void startPurchaseListener() { _purchaseSub = InAppPurchase.instance.purchaseStream.listen( _handlePurchases, onError: (Object error, StackTrace stackTrace) { // 记录错误并恢复按钮状态。 }, ); } ``` 状态处理: | `PurchaseStatus` | Flutter 行为 | | --- | --- | | `pending` | 展示处理中,不提交后端入账。 | | `purchased` | 提交后端确认接口。 | | `restored` | Android 一次性消耗品通常不依赖 restored;可以按补单逻辑提交后端去重。 | | `error` | 展示失败,恢复按钮。 | | `canceled` | 用户取消,不提示错误。 | ## 6. 确认 Google 支付 后端已实现接口: `POST /api/v1/wallet/payments/google/confirm` 登录接口。Flutter 在 `PurchaseStatus.purchased` 后调用。接口必须可重复调用;同一个 purchase token 重复提交只能返回同一笔入账结果。 请求头: | Header | 必填 | 说明 | | --- | --- | --- | | `Authorization` | 是 | App 登录 access token。 | | `X-App-Code` | 否 | App 编码,默认 `lalu`。 | | `X-App-Platform` | 是 | 固定传 `android`。 | 请求体: ```json { "command_id": "google-pay-", "product_id": 11, "product_code": "iap_android_11", "package_name": "com.example.hyapp", "purchase_token": "google_purchase_token", "order_id": "GPA.1111-2222-3333-44444", "purchase_time_ms": 1710000000000 } ``` 字段规则: | 字段 | 必填 | 说明 | | --- | --- | --- | | `command_id` | 是 | 客户端幂等 ID。同一个 purchase token 重试建议使用同一个值。后端最终还会按 purchase token 去重。 | | `product_id` | 是 | 后端商品列表返回的本地商品 ID。 | | `product_code` | 是 | 后端商品列表返回的 Google productId。 | | `package_name` | 是 | 当前 Android 包名。 | | `purchase_token` | 是 | `PurchaseDetails.verificationData.serverVerificationData`。 | | `order_id` | 否 | 插件能拿到时传;后端最终以 Google API 查询结果为准。 | | `purchase_time_ms` | 否 | 客户端购买时间,仅用于日志。 | `command_id` 建议由 token 派生,确保重试稳定: ```dart final String commandId = 'google-pay-${sha256Of(purchaseToken).substring(0, 24)}'; ``` 成功响应: ```json { "code": "OK", "message": "ok", "request_id": "req_abc", "data": { "payment_order_id": "gpay_xxx", "transaction_id": "wtx_xxx", "status": "credited", "product_id": 11, "product_code": "iap_android_11", "coin_amount": 1500, "balance": { "asset_type": "COIN", "available_amount": 12800, "frozen_amount": 0, "version": 9 }, "idempotent_replay": false, "consume_state": "consumed" } } ``` Flutter 成功处理: - `status=credited`:展示充值成功,刷新 `/wallet/me/balances`。 - `idempotent_replay=true`:按成功处理,不重复提示异常。 - `consume_state=consumed`:后端已完成 Google consume。 - `consume_state=consume_pending`:仍按到账成功处理;说明入账已成功但后端 consume 调用短时失败,下一次提交同一 token 会继续尝试 consume。 - 后端成功后调用 `InAppPurchase.instance.completePurchase(purchaseDetails)`,结束客户端 pending 状态。 失败处理: - `UNAUTHORIZED`:登录失效,走登录态恢复。 - `INVALID_ARGUMENT`:商品 ID、token、包名等参数错误,或 Google 判定 token 无效,记录 `request_id`。 - `CONFLICT`:商品未上架、商品区域不匹配、Google purchase state 不是 `PURCHASED`、Google productId/orderId 与后端商品不一致;不入账。 - `UPSTREAM_ERROR` / `INTERNAL_ERROR`:保留订单为待确认,稍后重试。 - provider 校验失败:不入账,不 complete purchase;记录 `request_id` 并触发补单或客服入口。 后端校验和审计规则: - 后端使用 Google Play Developer API `purchases.productsv2.getproductpurchasev2` 查询 purchase token,只接受 `purchaseState=PURCHASED`。 - Google 返回的 `productLineItem[0].productId` 必须等于后端商品 `product_code`。 - 后端以 `payment_orders(app_code, provider, purchase_token_hash)` 做 provider 幂等,同一个 token 不会重复发币。 - 后端入账成功后写 `wallet_transactions`、`wallet_entries`、`wallet_recharge_records`、`payment_orders` 和 `wallet_outbox`。 - 后端不会记录明文 `purchase_token`,只保存 SHA256 hash 和 Google 返回快照。 ## 7. 补单规则 Flutter 必须做补单,因为用户可能在支付成功后断网、杀进程或后端短时失败。 触发时机: - App 启动后。 - 登录成功后。 - 进入钱包页后。 - 购买确认接口返回 5xx 或网络失败后延迟重试。 处理规则: 1. 查询 Google Play 当前未完成购买。 2. 对每个 purchase token 查本地 pending 队列。 3. 重新调用 `/api/v1/wallet/payments/google/confirm`。 4. 后端返回 `credited` 后 complete purchase。 本地 pending 记录建议保存: ```json { "product_id": 11, "product_code": "iap_android_11", "purchase_token_hash": "sha256-token", "purchase_token": "secure-local-token", "command_id": "google-pay-xxxx", "created_at_ms": 1710000000000, "last_error": "network timeout" } ``` `purchase_token` 属于敏感数据。客户端日志不要打印明文 token;本地持久化也应尽量缩短保留时间。 ## 8. 余额刷新 充值确认成功后,Flutter 需要主动刷新余额: `GET /api/v1/wallet/me/balances?asset_type=COIN` 成功响应: ```json { "code": "OK", "message": "ok", "request_id": "req_abc", "data": { "balances": [ { "asset_type": "COIN", "available_amount": 12800, "frozen_amount": 0, "version": 9 } ] } } ``` 后端也会通过 IM 私信发送 `WalletBalanceChanged`,但 Flutter 不应只依赖 IM 通知。充值确认成功后主动刷新一次,IM 只作为实时补偿。 ## 9. Flutter 页面流程 推荐顺序: 1. 进入钱包页,调用 `/wallet/recharge/products`。 2. 用返回的 `product_code` 查询 Google `ProductDetails`。 3. 合并后展示充值档位。 4. 用户点击档位,调用 `buyConsumable(autoConsume: false)`。 5. purchase stream 收到 `purchased`。 6. 调用 `/wallet/payments/google/confirm`。 7. 后端返回 `credited`。 8. 调用 `completePurchase`。 9. 刷新 `/wallet/me/balances`。 10. 清理本地 pending 记录。 ## 10. 不允许的做法 - 不允许 Flutter 根据本地配置决定到账金币数。 - 不允许 Flutter 只看 Google 支付成功就本地加余额。 - 不允许客户端自动 consume 后再提交后端。 - 不允许把 `purchase_token`、Google 原始订单响应打印到普通日志。 - 不允许用价格文案匹配商品;必须用 `product_code` / Google productId 匹配。 ## 11. 与当前 Flutter 代码的差异 当前 `hyapp-flutter` 钱包页充值档位仍是本地假数据,后续需要替换为: - 新增 wallet recharge product API model。 - 在 `WalletLogic.onInit` 拉取 `/wallet/recharge/products`。 - 查询 Google `ProductDetails` 并合并展示。 - `topUp()` 从占位提示改为 Google Billing 购买。 - 新增 purchase stream listener 和 pending purchase 补单队列。