393 lines
13 KiB
Markdown
393 lines
13 KiB
Markdown
# 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: <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` 覆盖。 |
|
||
|
||
示例:
|
||
|
||
```http
|
||
GET /api/v1/wallet/recharge/products
|
||
Authorization: Bearer <access_token>
|
||
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<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`,该商品不展示,并记录客户端日志。
|
||
|
||
合并后的本地模型建议保留:
|
||
|
||
```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<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`。 |
|
||
|
||
请求体:
|
||
|
||
```json
|
||
{
|
||
"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 派生,确保重试稳定:
|
||
|
||
```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 补单队列。
|