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