13 KiB
Flutter Google Play 充值对接
本文描述 Android App 接入 Google Play 一次性金币充值的 Flutter 调用流程。Flutter 只负责购买流程和提交 Google purchase token;订单校验、到账金额、入账、幂等和消费确认都以后端 wallet-service 为准。
所有 gateway HTTP 接口都返回统一外壳:
{
"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:
dependencies:
in_app_purchase: <compatible_version>
Android 真机联调要求:
- App 使用正式包名和签名,包名必须与 Play Console App 一致。
- 测试账号加入 Play Console license testing 或内部测试轨道。
- Play Console 中的一次性商品必须激活,并覆盖测试账号所在地区。
3. 获取充值商品列表
GET /api/v1/wallet/recharge/products
登录接口,需要 Authorization: Bearer <access_token>。gateway 会根据当前登录用户资料里的 region_id 过滤商品;Flutter 不传用户 ID 和区域 ID。
请求头:
| Header | 必填 | 说明 |
|---|---|---|
Authorization |
是 | App 登录 access token。 |
X-App-Code |
否 | App 编码,默认 lalu。 |
X-App-Platform |
是 | 固定传 android。也可以用 query platform=android 覆盖。 |
示例:
GET /api/v1/wallet/recharge/products
Authorization: Bearer <access_token>
X-App-Code: lalu
X-App-Platform: android
成功响应:
{
"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:
final Set<String> 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,该商品不展示,并记录客户端日志。
合并后的本地模型建议保留:
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。
final PurchaseParam purchaseParam = PurchaseParam(
productDetails: sku.productDetails,
);
await InAppPurchase.instance.buyConsumable(
purchaseParam: purchaseParam,
autoConsume: false,
);
Flutter 必须监听购买流:
late final StreamSubscription<List<PurchaseDetails>> _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。 |
请求体:
{
"command_id": "google-pay-<stable-id>",
"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 派生,确保重试稳定:
final String commandId = 'google-pay-${sha256Of(purchaseToken).substring(0, 24)}';
成功响应:
{
"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 或网络失败后延迟重试。
处理规则:
- 查询 Google Play 当前未完成购买。
- 对每个 purchase token 查本地 pending 队列。
- 重新调用
/api/v1/wallet/payments/google/confirm。 - 后端返回
credited后 complete purchase。
本地 pending 记录建议保存:
{
"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
成功响应:
{
"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 页面流程
推荐顺序:
- 进入钱包页,调用
/wallet/recharge/products。 - 用返回的
product_code查询 GoogleProductDetails。 - 合并后展示充值档位。
- 用户点击档位,调用
buyConsumable(autoConsume: false)。 - purchase stream 收到
purchased。 - 调用
/wallet/payments/google/confirm。 - 后端返回
credited。 - 调用
completePurchase。 - 刷新
/wallet/me/balances。 - 清理本地 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 补单队列。