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