# 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 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 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 成功回执更新当前页面,其他页面进入时重新读取钱包余额。