# Fami 积分兑换金币 Flutter 对接

本文描述 Fami/Huwaa App 查看 POINT 兑换 COIN 比例和执行兑换的 HTTP 接口。页面文案可将 `POINT` 展示为“钻石积分”，但 Flutter 模型和日志建议保留真实资产类型。

## 1. 通用约定

Base URL 由 App 环境配置提供：

```text
线上：https://api.global-interaction.com
本地：http://127.0.0.1:13000
```

请求头：

| Header | 必填 | 说明 |
| --- | --- | --- |
| `Authorization: Bearer {access_token}` | 是 | 当前登录用户 token。 |
| `X-App-Code: fami` | 是 | Fami App 固定传 `fami`；不允许用户在页面切换 App。 |
| `Content-Type: application/json` | POST 是 | 兑换请求体为 JSON。 |

成功 envelope：

```json
{
  "code": "OK",
  "message": "ok",
  "request_id": "req_xxx",
  "data": {}
}
```

Flutter 只在 `code == "OK"` 时解析 `data`；失败日志记录 path、`code` 和 `request_id`，不记录 token。

## 2. 查看积分兑换比例

```http
GET /api/v1/point-wallet/exchange-config
Authorization: Bearer {access_token}
X-App-Code: fami
```

无 query 和 body。地区由 gateway 根据当前登录用户资料确定，Flutter 不提交 `region_id`。

成功响应：

```json
{
  "code": "OK",
  "message": "ok",
  "request_id": "req_config",
  "data": {
    "source_asset_type": "POINT",
    "target_asset_type": "COIN",
    "points_per_usd": 1000000,
    "coins_per_usd": 80000,
    "ratio_point_amount": 1000000,
    "ratio_coin_amount": 80000,
    "rounding_mode": "floor",
    "policy_instance_code": "fami_salary_policy_202607"
  }
}
```

字段说明：

| 字段 | 说明 |
| --- | --- |
| `points_per_usd` | 当前用户地区已发布工资政策中的 POINT/USD 比例。 |
| `coins_per_usd` | 统一工资兑金币比例，当前为 `80000`。 |
| `ratio_point_amount` / `ratio_coin_amount` | Flutter 直接展示的 POINT → COIN 比例。 |
| `rounding_mode` | 目标金币使用整数向下取整。 |
| `policy_instance_code` | 已发布政策快照编码，可用于客户端调试日志。 |

预览公式：

```text
coinAmount = floor(pointAmount * ratio_coin_amount / ratio_point_amount)
```

预览只用于界面展示，不能作为入账事实。如果 Admin 在用户确认前发布了新政策，兑换接口会在账变事务内重新读取当前政策。

## 3. 积分兑换金币

```http
POST /api/v1/point-wallet/exchange-to-coins
Authorization: Bearer {access_token}
X-App-Code: fami
Content-Type: application/json
```

请求参数：

| 字段 | 类型 | 必填 | 说明 |
| --- | --- | --- | --- |
| `command_id` | string | 是 | 最大 128 字节的幂等键，建议 `point_exchange_` + UUID v4。 |
| `point_amount` | int64 | 是 | 要扣减的 POINT 整数，必须大于 0。 |

客户端不提交 `user_id`、`region_id`、`coin_amount`、兑换比例或政策编码。

请求示例：

```json
{
  "command_id": "point_exchange_550e8400-e29b-41d4-a716-446655440000",
  "point_amount": 1000000
}
```

成功响应：

```json
{
  "code": "OK",
  "message": "ok",
  "request_id": "req_exchange",
  "data": {
    "transaction_id": "fami_xxx",
    "point_amount": 1000000,
    "coin_amount": 80000,
    "point_balance_after": 2500000,
    "coin_balance_after": 180000,
    "ratio_point_amount": 1000000,
    "ratio_coin_amount": 80000
  }
}
```

账务语义：

- wallet-service 在同一 MySQL 事务中扣减本人 `POINT` 并增加本人 `COIN`。
- Flutter 成功后必须用 `point_balance_after` 和 `coin_balance_after` 覆盖本地余额，不要用预览值自行加减。
- 超时或 5xx 重试必须复用原 `command_id` 和完全相同的 `point_amount`。用户发起新一次兑换时生成新 `command_id`。
- 最终兑换比例以成功回执的 `ratio_point_amount` / `ratio_coin_amount` 为准。

## 4. Flutter DTO 示例

```dart
class PointCoinExchangeConfig {
  const PointCoinExchangeConfig({
    required this.pointsPerUsd,
    required this.coinsPerUsd,
    required this.ratioPointAmount,
    required this.ratioCoinAmount,
    required this.roundingMode,
    required this.policyInstanceCode,
  });

  final int pointsPerUsd;
  final int coinsPerUsd;
  final int ratioPointAmount;
  final int ratioCoinAmount;
  final String roundingMode;
  final String policyInstanceCode;

  int previewCoins(int pointAmount) =>
      pointAmount * ratioCoinAmount ~/ ratioPointAmount;

  factory PointCoinExchangeConfig.fromJson(Map<String, dynamic> json) {
    return PointCoinExchangeConfig(
      pointsPerUsd: (json['points_per_usd'] as num).toInt(),
      coinsPerUsd: (json['coins_per_usd'] as num).toInt(),
      ratioPointAmount: (json['ratio_point_amount'] as num).toInt(),
      ratioCoinAmount: (json['ratio_coin_amount'] as num).toInt(),
      roundingMode: json['rounding_mode'] as String? ?? 'floor',
      policyInstanceCode: json['policy_instance_code'] as String? ?? '',
    );
  }
}

class PointCoinExchangeReceipt {
  const PointCoinExchangeReceipt({
    required this.transactionId,
    required this.pointAmount,
    required this.coinAmount,
    required this.pointBalanceAfter,
    required this.coinBalanceAfter,
    required this.ratioPointAmount,
    required this.ratioCoinAmount,
  });

  final String transactionId;
  final int pointAmount;
  final int coinAmount;
  final int pointBalanceAfter;
  final int coinBalanceAfter;
  final int ratioPointAmount;
  final int ratioCoinAmount;

  factory PointCoinExchangeReceipt.fromJson(Map<String, dynamic> json) {
    return PointCoinExchangeReceipt(
      transactionId: json['transaction_id'] as String? ?? '',
      pointAmount: (json['point_amount'] as num).toInt(),
      coinAmount: (json['coin_amount'] as num).toInt(),
      pointBalanceAfter: (json['point_balance_after'] as num).toInt(),
      coinBalanceAfter: (json['coin_balance_after'] as num).toInt(),
      ratioPointAmount: (json['ratio_point_amount'] as num).toInt(),
      ratioCoinAmount: (json['ratio_coin_amount'] as num).toInt(),
    );
  }
}
```

## 5. 常见错误

| HTTP | `code` | Flutter 处理 |
| --- | --- | --- |
| 400 | `INVALID_ARGUMENT` | `point_amount`/`command_id` 不合法，阻止提交并记录客户端参数问题。 |
| 401 | `UNAUTHORIZED` | 进入统一刷新 token/重新登录流程。 |
| 403 | `PERMISSION_DENIED` | 当前 App 不支持，或用户地区没有已发布兑换政策；隐藏兑换入口。 |
| 409 | `INSUFFICIENT_BALANCE` | 刷新 POINT 余额并提示余额不足。 |
| 409 | `IDEMPOTENCY_CONFLICT` | 同一 `command_id` 被用于不同参数；不要自动换键重试。 |
| 502/503 | `UPSTREAM_ERROR` / `UNAVAILABLE` | 保留原 `command_id`，允许用户重试原操作。 |

## 6. 相关 IM

无。积分兑换是私有钱包账变，不发送房间或 C2C IM。App 以 HTTP 成功回执更新当前页面，其他页面进入时重新读取钱包余额。
