# CP关系Flutter对接

本文只写 App 需要对接的地址、参数、返回值和相关 IM。CP 关系包含 `cp`、`brother`、`sister` 三种类型；CP 关系每个用户最多 1 个，兄弟和姐妹数量由后台 CP 配置控制；不需要佩戴接口。

## 1. 礼物面板CP Tab

地址：

```http
GET /api/v1/rooms/{room_id}/gift-panel
Authorization: Bearer <access_token>
X-App-Code: lalu
```

参数：

| 参数 | 类型 | 必填 | 说明 |
| --- | --- | --- | --- |
| `room_id` | string | 是 | 当前房间 ID |

返回值：

`tabs` 里会有 `gift_type_code=cp` 的 Tab。CP 礼物会带：

| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `gift_id` | string | 送礼时使用 |
| `gift_type_code` | string | CP 礼物为 `cp` |
| `cp_relation_type` | string | `cp`、`brother`、`sister` |
| `name` | string | 礼物名 |
| `coin_price` | int64 | 展示价格，扣费以后端为准 |
| `resource.asset_url` | string | 礼物 icon |
| `resource.animation_url` | string | 礼物动效，可为空 |

Flutter 在 CP 礼物 Tab 下按 `cp_relation_type` 分成 CP、兄弟、姐妹三个二级 Tab。

## 2. 发送CP礼物

地址：

```http
POST /api/v1/rooms/gift/send
Authorization: Bearer <access_token>
X-App-Code: lalu
Content-Type: application/json
```

参数：

| 字段 | 类型 | 必填 | 说明 |
| --- | --- | --- | --- |
| `room_id` | string | 是 | 当前房间 ID |
| `command_id` | string | 是 | 本次送礼幂等键，重试必须复用 |
| `target_type` | string | 否 | 当前传 `user` |
| `target_user_ids` | int64[] | 是 | CP 礼物只传 1 个收礼人 |
| `gift_id` | string | 是 | CP 礼物 ID |
| `gift_count` | int32 | 是 | 必须大于 0 |

示例：

```json
{
  "room_id": "room_1001",
  "command_id": "cp_gift_1779259000000_10001_10002",
  "target_type": "user",
  "target_user_ids": [10002],
  "gift_id": "cp_ring",
  "gift_count": 1
}
```

返回值：

沿用普通送礼响应。关系申请通过 IM 到达，送礼响应不保证返回申请 ID。

| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `result.applied` | bool | 本次命令是否落地 |
| `billing_receipt_id` | string | 扣费回执 |
| `room_heat` | int64 | 最新房间热度 |
| `gift_rank` | array | 房间礼物榜 |
| `room` | object | 房间快照 |
| `lucky_gift` | object | 幸运礼物结果，普通 CP 礼物为空 |

## 3. 查询我的申请

地址：

```http
GET /api/v1/cp/applications?direction=incoming&status=pending&page=1&page_size=20
Authorization: Bearer <access_token>
X-App-Code: lalu
```

参数：

| 参数 | 类型 | 必填 | 说明 |
| --- | --- | --- | --- |
| `direction` | string | 否 | `incoming` 我收到的；`outgoing` 我发出的 |
| `status` | string | 否 | `pending`、`accepted`、`rejected`、`expired`、`blocked` |
| `page` | int32 | 否 | 默认 1 |
| `page_size` | int32 | 否 | 默认 20 |

返回值：

```json
{
  "applications": [
    {
      "application_id": "cp_app_10001_10002_cp",
      "relation_type": "cp",
      "status": "pending",
      "requester": {
        "user_id": "10001",
        "display_user_id": "123456",
        "username": "Alice",
        "avatar": "https://cdn.example/a.png"
      },
      "target": {
        "user_id": "10002",
        "display_user_id": "654321",
        "username": "Bob",
        "avatar": "https://cdn.example/b.png"
      },
      "room_id": "room_1001",
      "room_region_id": 86,
      "gift": {
        "gift_id": "cp_ring",
        "gift_name": "CP Ring",
        "gift_icon_url": "https://cdn.example/cp-ring.png",
        "gift_animation_url": "",
        "gift_count": 1,
        "gift_value": 520,
        "billing_receipt_id": "gift_tx_1"
      },
      "created_at_ms": 1779259000000,
      "updated_at_ms": 1779259000000,
      "expires_at_ms": 1779345400000,
      "decided_at_ms": 0
    }
  ],
  "total": 1,
  "server_time_ms": 1779259001000
}
```

## 4. 同意申请

地址：

```http
POST /api/v1/cp/applications/{application_id}/accept
Authorization: Bearer <access_token>
X-App-Code: lalu
```

参数：

| 参数 | 类型 | 必填 | 说明 |
| --- | --- | --- | --- |
| `application_id` | string | 是 | 申请 ID |

返回值：

```json
{
  "application": {
    "application_id": "cp_app_10001_10002_cp",
    "relation_type": "cp",
    "status": "accepted",
    "decided_at_ms": 1779259100000
  },
  "relationship": {
    "relationship_id": "cp_rel_10001_10002",
    "relation_type": "cp",
    "status": "active",
    "me": {
      "user_id": "10002",
      "display_user_id": "654321",
      "username": "Bob",
      "avatar": "https://cdn.example/b.png"
    },
    "partner": {
      "user_id": "10001",
      "display_user_id": "123456",
      "username": "Alice",
      "avatar": "https://cdn.example/a.png"
    },
    "intimacy_value": 0,
    "level": 1,
    "formed_at_ms": 1779259100000,
    "updated_at_ms": 1779259100000
  }
}
```

错误处理：

| code | 处理 |
| --- | --- |
| `CONFLICT` | A/B 任一方已经有 active 关系，或申请已过期；刷新申请和关系列表 |
| `NOT_FOUND` | 申请不存在，刷新申请列表 |
| `PERMISSION_DENIED` | 当前用户不是申请目标用户 |

## 5. 拒绝申请

地址：

```http
POST /api/v1/cp/applications/{application_id}/reject
Authorization: Bearer <access_token>
X-App-Code: lalu
Content-Type: application/json
```

参数：

| 参数 | 类型 | 必填 | 说明 |
| --- | --- | --- | --- |
| `application_id` | string | 是 | 申请 ID |
| `reason` | string | 否 | 拒绝原因 |

返回值：

```json
{
  "application": {
    "application_id": "cp_app_10001_10002_cp",
    "relation_type": "cp",
    "status": "rejected",
    "decided_at_ms": 1779259100000
  }
}
```

## 6. 查询我的关系

地址：

```http
GET /api/v1/cp/relationships?relation_type=cp&page=1&page_size=20
Authorization: Bearer <access_token>
X-App-Code: lalu
```

参数：

| 参数 | 类型 | 必填 | 说明 |
| --- | --- | --- | --- |
| `relation_type` | string | 否 | `cp`、`brother`、`sister`；不传返回全部 |
| `page` | int32 | 否 | 默认 1 |
| `page_size` | int32 | 否 | 默认 20 |

返回值：

```json
{
  "relationships": [
    {
      "relationship_id": "cp_rel_10001_10002",
      "relation_type": "cp",
      "status": "active",
      "me": {
        "user_id": "10001",
        "display_user_id": "123456",
        "username": "Alice",
        "avatar": "https://cdn.example/a.png"
      },
      "partner": {
        "user_id": "10002",
        "display_user_id": "654321",
        "username": "Bob",
        "avatar": "https://cdn.example/b.png"
      },
      "intimacy_value": 1888,
      "level": 2,
      "current_level_threshold": 1000,
      "next_level_threshold": 3000,
      "needed_for_next_level": 1112,
      "level_progress_percent": 44,
      "max_level": false,
      "breakup_cost_coins": 88,
      "formed_at_ms": 1779259100000,
      "ended_at_ms": 0,
      "updated_at_ms": 1779259200000
    }
  ],
  "total": 1
}
```

说明：

- CP 关系每个用户最多 1 个；兄弟和姐妹可拥有数量以后台配置为准。
- A/B 两个用户之间同一时间只能存在一种 active 关系，不能同时是 CP、兄弟或姐妹。
- 不需要佩戴接口，资料卡直接展示当前 active 关系。
- 资料卡弹窗只使用后端返回的 `intimacy_value`、`level`、`current_level_threshold`、`next_level_threshold`、`needed_for_next_level`、`level_progress_percent` 和 `max_level`。
- `max_level=true` 或 `next_level_threshold=0` 时表示没有下一等级，隐藏“距离下一级”和进度条。
- Flutter 不要用本地常量、礼物价格或后台配置接口自行推算进度条和“距离下一级”。
- `breakup_cost_coins` 是解除当前关系需要支付的金币数，弹窗和二次确认直接使用该值。

## 7. 解除关系

地址：

```http
POST /api/v1/cp/relationships/{relationship_id}/break
Authorization: Bearer <access_token>
X-App-Code: lalu
Content-Type: application/json

{
  "command_id": "break_cp_rel_10001_10002_1779259300000"
}
```

参数：

| 参数 | 类型 | 必填 | 说明 |
| --- | --- | --- | --- |
| `relationship_id` | string | 是 | URL path，关系 ID |
| `command_id` | string | 是 | 解除关系幂等键，同一次重试必须复用 |

返回值：

```json
{
  "relationship": {
    "relationship_id": "cp_rel_10001_10002",
    "relation_type": "cp",
    "status": "ended",
    "breakup_cost_coins": 88,
    "formed_at_ms": 1779259100000,
    "ended_at_ms": 1779259300000,
    "updated_at_ms": 1779259300000
  },
  "breakup_cost_coins": 88,
  "wallet_transaction_id": "wallet_tx_break_1",
  "coin_balance_after": 912
}
```

说明：

- 解除前先展示 `/api/v1/cp/relationships` 返回的 `breakup_cost_coins`。
- 金币不足时接口返回 `INSUFFICIENT_BALANCE`，关系仍保持 active。
- 解除成功不发 C2C、房间 IM 或区域 IM；Flutter 使用接口返回刷新资料卡和关系列表。

## 8. 相关IM

所有 IM 都是 Tencent `TIMCustomElem`，`MsgContent.Desc` 等于下表事件名，`MsgContent.Data` 是 JSON 字符串。客户端按 `event_id` 去重。

### 8.1 收到关系申请

房间 IM 和 C2C IM 都会收到：

| 字段 | 值 |
| --- | --- |
| `MsgContent.Desc` | `cp_application_created` |
| `Data.event_type` | `cp_application_created` |

Data：

```json
{
  "event_id": "cp_application:created:10002:1",
  "event_type": "cp_application_created",
  "application_id": "cp_app_10001_10002_cp",
  "relation_type": "cp",
  "status": "pending",
  "requester": {
    "user_id": "10001",
    "display_user_id": "123456",
    "username": "Alice",
    "avatar": "https://cdn.example/a.png"
  },
  "target": {
    "user_id": "10002",
    "display_user_id": "654321",
    "username": "Bob",
    "avatar": "https://cdn.example/b.png"
  },
  "room_id": "room_1001",
  "room_region_id": 86,
  "gift": {
    "gift_id": "cp_ring",
    "gift_name": "CP Ring",
    "gift_icon_url": "https://cdn.example/cp-ring.png",
    "gift_animation_url": "",
    "gift_count": 1,
    "gift_value": 520,
    "billing_receipt_id": "gift_tx_1"
  },
  "expires_at_ms": 1779345400000,
  "decided_at_ms": 0,
  "source_created_at_ms": 1779259000000,
  "recipient_user_id": "10002"
}
```

处理：

- 房间 IM 只有 `recipient_user_id` 等于当前用户时显示同意/拒绝按钮。
- C2C IM 在私聊里显示同意/拒绝按钮。
- `expires_at_ms <= server_time_ms` 时按钮置灰并刷新申请列表。

### 8.2 申请被同意

C2C 会收到双方互发的两条消息，房间 IM 仍然只给申请发起人 A 展示：

| 字段 | 值 |
| --- | --- |
| `MsgContent.Desc` | `cp_application_accepted` |
| `Data.event_type` | `cp_application_accepted` |

Data 字段和申请消息一致，差异是：

| 字段 | 值 |
| --- | --- |
| `status` | `accepted` |
| `decided_at_ms` | 处理时间 |
| `recipient_user_id` | 房间 IM 中为 A 的 user_id |

C2C 差异：

| 方向 | `c2c_notice_role` | `message_text` |
| --- | --- | --- |
| B -> A | `accepted_to_requester` | `我同意了你` |
| A -> B | `accepted_to_acceptor` | `对方已同意你的申请` |

处理：

- A 收到后刷新 `/api/v1/cp/relationships`。
- B 操作成功后也刷新关系列表。
- 同一对用户其他 pending 申请需要置灰。

### 8.3 申请被拒绝

C2C 和房间 IM 都会收到：

| 字段 | 值 |
| --- | --- |
| `MsgContent.Desc` | `cp_application_rejected` |
| `Data.event_type` | `cp_application_rejected` |

Data 字段和申请消息一致，差异是：

| 字段 | 值 |
| --- | --- |
| `status` | `rejected` |
| `reason` | 拒绝原因，可为空 |
| `decided_at_ms` | 处理时间 |
| `recipient_user_id` | 房间 IM 中为 A 的 user_id |

### 8.4 区域关系成立播报

用户组成任意关系后，会发送区域 IM：

| 字段 | 值 |
| --- | --- |
| `MsgContent.Desc` | `cp_relationship_created` |
| `Data.event_type` | `cp_relationship_created` |

Data：

```json
{
  "event_id": "cp_relationship:created:10002:1",
  "event_type": "cp_relationship_created",
  "relationship_id": "cp_rel_10001_10002",
  "application_id": "cp_app_10001_10002_cp",
  "relation_type": "cp",
  "requester": {
    "user_id": "10001",
    "display_user_id": "123456",
    "username": "Alice",
    "avatar": "https://cdn.example/a.png"
  },
  "target": {
    "user_id": "10002",
    "display_user_id": "654321",
    "username": "Bob",
    "avatar": "https://cdn.example/b.png"
  },
  "gift": {
    "gift_id": "cp_ring",
    "gift_name": "CP Ring",
    "gift_icon_url": "https://cdn.example/cp-ring.png",
    "gift_animation_url": "",
    "gift_count": 1,
    "gift_value": 520,
    "billing_receipt_id": "gift_tx_1"
  },
  "room_id": "room_1001",
  "room_region_id": 86,
  "group_id": "hy_lalu_bc_r_86",
  "source_created_at_ms": 1779259100000
}
```

处理：

- 只在区域播报位展示，不当作私聊消息。
- 点击可进入 `room_id` 对应房间；房间不存在或进房失败时只关闭播报。

## 8. 客户端错误提示

| code | 建议提示 |
| --- | --- |
| `CONFLICT` | `你或对方已经有关系`；如果申请已过期则提示 `申请已过期` |
| `NOT_FOUND` | `申请不存在或已失效` |
| `PERMISSION_DENIED` | `只能处理发给你的申请` |

## 9. 本地状态规则

- 不要靠送礼成功直接创建关系。
- 不要靠 C2C 或房间按钮本地改关系，必须调用同意或拒绝接口。
- 同一个 `event_id` 只处理一次。
- 同一个 `application_id` 在房间和 C2C 里可能都出现，按钮状态要共享。
- 申请过期时间以 `expires_at_ms` 为准。
- 亲密值和等级以后端关系接口返回为准，不在 Flutter 里按礼物价格推算。
- 进度条和距离下一级只使用关系接口返回的阈值字段；字段缺失时不展示，不要伪造 `next_level_threshold`、`needed_for_next_level` 或进度百分比。
