hyapp-server/docs/flutter对接/Google Play充值Flutter对接.md
2026-05-26 01:02:12 +08:00

393 lines
13 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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 补单队列。