# Flutter App 通知与钱包余额对接

本文只定义 App 需要对接的 HTTP 接口和 IM 私有事件契约，不约束 Flutter 端内部状态管理实现。

## 通用 HTTP 约定

### Base URL

```text
https://{api_host}
```

本地开发：

```text
http://127.0.0.1:13000
```

### Headers

| Header                                 | 必填      | 说明                                         |
| -------------------------------------- | --------- | -------------------------------------------- |
| `Authorization: Bearer {access_token}` | 是        | 登录后后端返回的 App 访问令牌                |
| `Content-Type: application/json`       | POST 必填 | 请求体为 JSON                                |
| `X-App-Code`                           | 否        | 多 App 包场景可传；通常由 gateway 按包名解析 |
| `X-App-Platform`                       | 否        | `android` / `ios`，部分钱包商品接口使用      |

### 响应 Envelope

所有 `/api/v1` 业务接口使用统一响应：

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

失败时没有 `data`：

```json
{
  "code": "UNAUTHORIZED",
  "message": "unauthorized",
  "request_id": "req_xxx"
}
```

## 错误代码

| HTTP | code                   | message                  | 说明                            |
| ---- | ---------------------- | ------------------------ | ------------------------------- |
| 200  | `OK`                   | `ok`                     | 成功                            |
| 400  | `INVALID_JSON`         | `invalid json`           | JSON 格式错误                   |
| 400  | `INVALID_ARGUMENT`     | `invalid argument`       | 参数缺失或非法                  |
| 401  | `UNAUTHORIZED`         | `unauthorized`           | 未登录、token 过期或 token 无效 |
| 403  | `PROFILE_REQUIRED`     | `profile required`       | 用户资料未完成                  |
| 403  | `PERMISSION_DENIED`    | `permission denied`      | 无权限                          |
| 404  | `NOT_FOUND`            | `not found`              | 资源不存在                      |
| 409  | `INSUFFICIENT_BALANCE` | `insufficient balance`   | 金币/余额不足                   |
| 409  | `LEDGER_CONFLICT`      | `conflict`               | 钱包幂等冲突                    |
| 409  | `IDEMPOTENCY_CONFLICT` | `conflict`               | `command_id` 重复但请求内容不同 |
| 409  | `ROOM_CLOSED`          | `room closed`            | 房间已关闭                      |
| 409  | `CONFLICT`             | `conflict`               | 当前状态不允许操作              |
| 429  | `RATE_LIMITED`         | `rate limited`           | 触发限流                        |
| 500  | `INTERNAL_ERROR`       | `internal error`         | 服务端内部错误                  |
| 502  | `UPSTREAM_ERROR`       | `upstream service error` | 内部依赖不可用                  |

App 处理建议：`UNAUTHORIZED` 重新登录；`UPSTREAM_ERROR` 可提示重试；`INSUFFICIENT_BALANCE` 引导充值；其它错误按页面语义提示。

## 1. 获取腾讯 IM 登录票据

```text
GET /api/v1/im/usersig
```

### Headers

| Header          | 必填 |
| --------------- | ---- |
| `Authorization` | 是   |

### Query 参数

无。

### 返回值 `data`

```json
{
  "sdk_app_id": 1400000000,
  "user_id": "10001",
  "user_sig": "xxx",
  "expire_at_ms": 1710000000000,
  "join_groups": [
    {
      "group_id": "hy_lalu_bc_g",
      "type": "global_broadcast",
      "region_id": 0
    }
  ]
}
```

| 字段           | 类型   | 说明                                   |
| -------------- | ------ | -------------------------------------- |
| `sdk_app_id`   | int64  | 腾讯云 IM AppID                        |
| `user_id`      | string | 腾讯云 IM identifier，直接用于 IM 登录 |
| `user_sig`     | string | 腾讯云 IM 登录票据                     |
| `expire_at_ms` | int64  | 票据过期时间，UTC epoch ms             |
| `join_groups`  | array  | App 登录 IM 后需要加入的服务端群       |

### 可能错误

`UNAUTHORIZED`、`PROFILE_REQUIRED`、`PERMISSION_DENIED`、`INTERNAL_ERROR`、`UPSTREAM_ERROR`。

## 2. 获取我的钱包余额

```text
GET /api/v1/wallet/me/balances
```

### Headers

| Header          | 必填 |
| --------------- | ---- |
| `Authorization` | 是   |

### Query 参数

| 参数          | 类型            | 必填 | 说明                                          |
| ------------- | --------------- | ---- | --------------------------------------------- |
| `asset_type`  | string/repeated | 否   | 可重复传，也可逗号分隔；不传默认只返回 `COIN` |
| `asset_types` | string/repeated | 否   | 同 `asset_type`                               |

示例：

```text
GET /api/v1/wallet/me/balances?asset_type=COIN&asset_type=DIAMOND&asset_type=HOST_SALARY_USD&asset_type=AGENCY_SALARY_USD&asset_type=BD_SALARY_USD&asset_type=ADMIN_SALARY_USD
```

### 返回值 `data`

```json
{
  "balances": [
    {
      "asset_type": "COIN",
      "available_amount": 1123123,
      "frozen_amount": 0,
      "version": 18
    }
  ]
}
```

| 字段               | 类型   | 说明                                              |
| ------------------ | ------ | ------------------------------------------------- |
| `asset_type`       | string | `COIN` / `DIAMOND` / `HOST_SALARY_USD` / `AGENCY_SALARY_USD` / `BD_SALARY_USD` / `ADMIN_SALARY_USD` / `GIFT_POINT` |
| `available_amount` | int64  | 可用余额                                          |
| `frozen_amount`    | int64  | 冻结余额                                          |
| `version`          | int64  | 该资产余额版本，用于和 IM 通知去重/防乱序         |

### 可能错误

`UNAUTHORIZED`、`PROFILE_REQUIRED`、`INVALID_ARGUMENT`、`UPSTREAM_ERROR`。

## 3. 获取钱包首页摘要

```text
GET /api/v1/wallet/me/overview
```

### Headers

| Header          | 必填 |
| --------------- | ---- |
| `Authorization` | 是   |

### Query 参数

无。

### 返回值 `data`

```json
{
  "balances": [
    {
      "asset_type": "COIN",
      "available_amount": 1123123,
      "frozen_amount": 0,
      "version": 18
    },
    {
      "asset_type": "DIAMOND",
      "available_amount": 200,
      "frozen_amount": 0,
      "version": 4
    },
    {
      "asset_type": "HOST_SALARY_USD",
      "available_amount": 1500,
      "frozen_amount": 0,
      "version": 2
    }
  ],
  "feature_flags": {
    "recharge_enabled": true,
    "diamond_exchange_enabled": true
  }
}
```

| 字段                                     | 类型  | 说明                       |
| ---------------------------------------- | ----- | -------------------------- |
| `balances`                               | array | 钱包首页需要展示的资产余额 |
| `feature_flags.recharge_enabled`         | bool  | 是否允许充值               |
| `feature_flags.diamond_exchange_enabled` | bool  | 是否允许钻石兑换           |

### 可能错误

`UNAUTHORIZED`、`PROFILE_REQUIRED`、`UPSTREAM_ERROR`。

## 4. 送礼

```text
POST /api/v1/rooms/gift/send
```

### Headers

| Header                           | 必填 |
| -------------------------------- | ---- |
| `Authorization`                  | 是   |
| `Content-Type: application/json` | 是   |

### Body 参数

```json
{
  "room_id": "lalu_xxx",
  "command_id": "gift_1700000000000_10001",
  "target_type": "user",
  "target_user_ids": [10002],
  "gift_id": "car_001",
  "gift_count": 1
}
```

| 参数              | 类型    | 必填 | 说明                                                      |
| ----------------- | ------- | ---- | --------------------------------------------------------- |
| `room_id`         | string  | 是   | 房间 ID                                                   |
| `command_id`      | string  | 是   | 本次用户动作幂等键；HTTP 重试必须复用同一个值             |
| `target_type`     | string  | 否   | 当前支持 `user`；后续扩展 `all_mic`、`all_room`、`couple` |
| `target_user_ids` | int64[] | 是   | 收礼用户 ID；`target_type=user` 时必须且只能 1 个         |
| `target_user_id`  | int64   | 否   | 单目标兼容字段；新代码优先用 `target_user_ids`            |
| `gift_id`         | string  | 是   | 礼物 ID                                                   |
| `gift_count`      | int32   | 是   | 礼物数量                                                  |

### 返回值 `data`

```json
{
  "result": {
    "applied": true,
    "room_version": 14,
    "server_time_ms": 1710000000000
  },
  "billing_receipt_id": "br_xxx",
  "room_heat": 123456,
  "gift_rank": [],
  "room": {}
}
```

| 字段                    | 类型   | 说明                                       |
| ----------------------- | ------ | ------------------------------------------ |
| `result.applied`        | bool   | 本次命令是否实际落地；幂等重放可能为 false |
| `result.room_version`   | int64  | 房间版本                                   |
| `result.server_time_ms` | int64  | 服务端时间                                 |
| `billing_receipt_id`    | string | 钱包扣费回执                               |
| `room_heat`             | int64  | 房间热度                                   |
| `gift_rank`             | array  | 房间礼物榜快照                             |
| `room`                  | object | 房间快照                                   |

送礼成功不代表 App 必须从该响应里更新钱包余额。金币余额以 `WalletBalanceChanged` IM 私有事件或 `/wallet/me/balances` 刷新结果为准。

### 可能错误

`UNAUTHORIZED`、`PROFILE_REQUIRED`、`INVALID_ARGUMENT`、`INSUFFICIENT_BALANCE`、`ROOM_CLOSED`、`LEDGER_CONFLICT`、`IDEMPOTENCY_CONFLICT`、`UPSTREAM_ERROR`、`INTERNAL_ERROR`。

## IM 私有事件：钱包余额变更

### 事件名称

```text
WalletBalanceChanged
```

### IM 通道

腾讯云 IM C2C 自定义消息。

| 字段                | 值                                         |
| ------------------- | ------------------------------------------ |
| `MsgType`           | `TIMCustomElem`                            |
| `Desc`              | `WalletBalanceChanged`                     |
| `Ext` / `extension` | `wallet_notice`                            |
| `To_Account`        | 当前登录用户的 IM identifier               |
| `CloudCustomData`   | 与 `TIMCustomElem.Data` 相同的 JSON 字符串 |

### Headers

无。IM 私有事件不是 HTTP 请求，没有 HTTP header。

### 参数 / Payload

`TIMCustomElem.Data` 是 JSON 字符串：

```json
{
  "event_type": "WalletBalanceChanged",
  "event_id": "wev_xxx",
  "app_code": "lalu",
  "transaction_id": "tx_xxx",
  "command_id": "gift_1700000000000_10001",
  "user_id": "10001",
  "asset_type": "COIN",
  "available_delta": -9900,
  "frozen_delta": 0,
  "available_after": 1123123,
  "frozen_after": 0,
  "balance_version": 18,
  "created_at_ms": 1710000000000,
  "source_created_at_ms": 1710000000000,
  "metadata": {}
}
```

| 字段                   | 类型   | 必填 | 说明                             |
| ---------------------- | ------ | ---- | -------------------------------- |
| `event_type`           | string | 是   | 固定 `WalletBalanceChanged`      |
| `event_id`             | string | 是   | 事件幂等键                       |
| `app_code`             | string | 是   | App 租户                         |
| `transaction_id`       | string | 是   | 钱包交易 ID                      |
| `command_id`           | string | 是   | 触发该余额变化的业务命令 ID      |
| `user_id`              | string | 是   | 当前用户 ID 字符串               |
| `asset_type`           | string | 是   | 资产类型                         |
| `available_delta`      | int64  | 是   | 可用余额变化量                   |
| `frozen_delta`         | int64  | 是   | 冻结余额变化量                   |
| `available_after`      | int64  | 是   | 变化后的可用余额                 |
| `frozen_after`         | int64  | 是   | 变化后的冻结余额                 |
| `balance_version`      | int64  | 是   | 该用户该资产的新版本             |
| `created_at_ms`        | int64  | 是   | 钱包事件创建时间                 |
| `source_created_at_ms` | int64  | 是   | 源 outbox 创建时间               |
| `metadata`             | object | 否   | 业务附加信息，不作为余额展示依赖 |

### 返回值

无。该事件由服务端单向投递给 App，App 不需要回调后端确认。

### 错误代码

无 HTTP 错误代码。App 本地处理规则：

| 场景                                              | 处理                                         |
| ------------------------------------------------- | -------------------------------------------- |
| `Desc` 不是 `WalletBalanceChanged`                | 忽略                                         |
| `Ext/extension` 不是 `wallet_notice`              | 忽略                                         |
| JSON 解析失败                                     | 忽略并记录客户端日志                         |
| `event_id` 已处理                                 | 忽略                                         |
| 本地 `asset_type` 的 `version >= balance_version` | 忽略                                         |
| 长时间未收到通知                                  | 调 `GET /api/v1/wallet/me/balances` 兜底刷新 |

## App 最小对接顺序

1. 登录后调用 `GET /api/v1/im/usersig`。
2. 用返回的 `user_id/user_sig/sdk_app_id` 登录腾讯云 IM。
3. 调 `GET /api/v1/wallet/me/balances` 拉取初始余额。
4. 监听 C2C 自定义消息 `WalletBalanceChanged`。
5. 送礼调用 `POST /api/v1/rooms/gift/send`。
6. 送礼后的余额以 IM 私有事件或余额刷新接口为准。
