hyapp-server/docs/flutter对接/Fami积分兑换金币Flutter对接.md
2026-07-20 16:48:30 +08:00

218 lines
6.7 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.

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