# CP关系服务端开发文档

本文定义当前 CP、兄弟、姐妹关系的服务端边界。关系事实归 `user-service`，送礼扣费归 `wallet-service`，房间送礼事实归 `room-service`，CP 相关 IM 由 `notice-service` 消费 `user_outbox` 后补偿投递。

## 1. 关系规则

| 类型 | 编码 | 说明 |
| --- | --- | --- |
| CP | `cp` | CP 关系 |
| 兄弟 | `brother` | 兄弟关系 |
| 姐妹 | `sister` | 姐妹关系 |

规则：

- CP 关系每个用户最多 1 个，不提供可调数量。
- 兄弟和姐妹的每人 active 数量由后台 CP 配置控制。
- A 和 B 同时只能存在一种 active 关系。
- A 可以给 B 发送 `cp`、`brother`、`sister` 多种 CP 礼物，形成多条 pending 申请。
- B 接受其中一个申请后，同一对用户其他 pending 申请会被置为 `blocked`，不能再接受。
- 申请默认 24 小时过期。
- 不提供佩戴 CP 功能；资料卡如果要展示关系，直接展示用户当前 active 关系。

## 2. 服务归属

| 服务 | 负责内容 |
| --- | --- |
| `wallet-service` | 礼物配置、扣费、热度值、礼物 icon 和动画快照 |
| `room-service` | 送礼命令、房间热度、房间礼物 IM、`RoomGiftSent` outbox |
| `user-service` | CP 申请、关系、亲密值、等级、等级奖励幂等、`user_outbox` |
| `gateway-service` | App HTTP 接口、鉴权、request_id 透传 |
| `notice-service` | 申请、同意、拒绝、关系成立的 C2C/房间/区域 IM |
| `cron-service` | 定时触发 user-service 过期申请处理 |

边界：

- `room-service` 不保存 CP 关系状态。
- `user-service` 不直连 `wallet-service`、`room-service` 或 `notice-service` 数据库。
- CP 申请和亲密值变化只能消费 `RoomGiftSent` MQ 事实，不能扫 `room_outbox`。
- 下游服务只能消费 owner service 发布到 RocketMQ 的 outbox。

## 3. CP礼物配置

礼物大类使用 `gift_type_code=cp`。CP 子类型使用发送时快照字段 `cp_relation_type`，值为 `cp`、`brother`、`sister`。

| 配置 | 说明 |
| --- | --- |
| 礼物类型 | `gift_type_code=cp`，礼物面板展示为 CP 礼物 Tab |
| CP 子类型 | `cp_relation_type=cp/brother/sister`，用于 CP Tab 二级分类 |
| 礼物 icon | 来自绑定资源的 `asset_url`，发送事件保存快照 |
| 礼物价格和热度 | 继续由 `wallet-service` 结算，客户端不能传价格或热度 |

`cp_relation_type` 写入礼物 `presentation_json`：

```json
{
  "cp_relation_type": "cp",
  "cp_relation_label": "CP礼物"
}
```

## 4. RoomGiftSent字段

`api/proto/events/room/v1/events.proto` 的 `RoomGiftSent` 必须带以下送礼快照：

| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `gift_type_code` | string | CP 礼物为 `cp` |
| `cp_relation_type` | string | `cp`、`brother`、`sister`；非 CP 礼物为空 |
| `gift_name` | string | 礼物名称 |
| `gift_icon_url` | string | 礼物 icon |
| `gift_animation_url` | string | 礼物动画，可为空 |

## 5. 送礼触发申请

```mermaid
sequenceDiagram
  participant F as Flutter
  participant G as gateway-service
  participant R as room-service
  participant W as wallet-service
  participant U as user-service
  participant N as notice-service
  participant IM as Tencent IM

  F->>G: POST /api/v1/rooms/gift/send
  G->>R: SendGift
  R->>W: DebitGift
  W-->>R: heat_value + gift_type_code + cp_relation_type + gift snapshot
  R->>R: update heat/rank/command log
  R-->>F: SendGiftResponse
  R-->>U: RoomGiftSent MQ
  U->>U: check relationship capacity
  alt either user has available capacity
    U->>U: create or refresh CP application
    U-->>N: UserCPApplicationCreated MQ
    N->>IM: A -> B C2C cp_application_created
    N->>IM: room cp_application_created, recipient_user_id=B
  else either user has reached capacity
    U-->>N: UserCPRelationshipCapacityReached MQ
    N->>IM: system -> A C2C cp_relationship_capacity_reached
  end
```

处理规则：

- 只有 `gift_type_code=cp` 且 `cp_relation_type` 有效时创建申请。
- 同一个 `RoomGiftSent.event_id` 只能消费一次。
- A 给 B 发送同一类型 CP 礼物时，如果已有 pending 申请，刷新礼物快照和 `expires_at_ms`。
- A 给 B 发送不同类型 CP 礼物时，可以各有一条 pending 申请。
- A/B 已经是 active 关系时，双方互送任意礼物只增加亲密值，不重复创建申请。
- A/B 尚无 active 关系，但任一方该类型 active 关系数量已达上限时，不创建或刷新申请；user-service 同事务写入消费位点和 `UserCPRelationshipCapacityReached` outbox，notice-service 再由腾讯 IM 管理员账号通知送礼人 A。
- 容量提示 payload 使用 `event_type=cp_relationship_capacity_reached`、`recipient_user_id=A`、`message_text=用户关系已经达到上限`；App 按 `event_id` 去重并展示轻提示。
- 送礼扣费和房间表现先于异步容量判断完成；容量已满只阻止创建无效申请，不回滚礼物账务。

## 6. 同意和拒绝

```mermaid
sequenceDiagram
  participant F as Flutter B
  participant G as gateway-service
  participant U as user-service
  participant N as notice-service
  participant IM as Tencent IM

  F->>G: POST /api/v1/cp/applications/{id}/accept
  G->>U: AcceptCPApplication
  U->>U: lock pair + lock both users + create relationship
  U-->>N: UserCPApplicationAccepted MQ
  U-->>N: UserCPRelationshipCreated MQ
  N->>IM: B -> A C2C cp_application_accepted
  N->>IM: A -> B C2C cp_application_accepted
  N->>IM: room cp_application_accepted, recipient_user_id=A
  N->>IM: region cp_relationship_created
```

处理规则：

- 接受和拒绝都只能由申请目标用户 B 操作。
- pending 申请过期后不能接受，gateway envelope 返回 `CONFLICT`；内部 gRPC 语义是 FailedPrecondition。
- 接受时同时校验 A 和 B 是否已经有任意 active 关系；任一方已有关系就返回 `CONFLICT`。
- 同意成功后写 active 关系，关闭同一对用户其他 pending 申请。
- 同意会发两条 C2C：`B -> A` 表示“我同意了你”，`A -> B` 表示“对方已同意你的申请”。
- 同意和拒绝都会向 A 发送房间 IM，用 `recipient_user_id=A` 限定展示对象。
- 拒绝仍只向 A 发送一条 C2C 和一条房间 IM。
- 同意成功后额外发送区域 IM，区域取申请来源房间的 `visible_region_id`。

## 7. 亲密值和升级

任意礼物发送成功后，如果发送方和收礼方已经有 active 关系，则按本次礼物的 `heat_value` 增加亲密值。

规则：

- 普通礼物按 100% 热度值增加亲密值。
- 幸运礼物、超级幸运礼物直接使用钱包回执里的最终 `heat_value`，不重复计算比例。
- 多目标送礼时按每个目标的账务回执分别增加对应关系亲密值。
- 等级范围 1 到 5；等级亲密值阈值和奖励资源组由后台 CP 配置维护。
- 每个等级可绑定奖励资源组，升级时调用 `wallet-service` 给双方发放资源。
- 奖励发放幂等键为 `cp_level_reward:{relationship_id}:{level}:{user_id}`。
- App 关系查询返回当前等级阈值、下一等级阈值、距离下一级和进度百分比。
- 资料卡弹窗只能使用 `/api/v1/cp/relationships` 返回的阈值字段展示进度；客户端不能读取后台配置或硬编码等级表。

## 8. 解除关系

用户可以主动解除自己的 active 关系。解除费用由后台 CP 配置按关系类型维护，单位是金币。

规则：

- App 先通过 `/api/v1/cp/relationships` 读取 `breakup_cost_coins` 展示费用。
- 解除接口必须传 `command_id`，同一次解除重试复用同一个 `command_id`。
- gateway 先在 user-service 创建解除占位，再调用 wallet-service 扣金币，最后确认关系为 `ended`。
- 如果 wallet-service 扣费失败，user-service 会取消解除占位，关系仍保持 active。
- 解除成功后不会发送 C2C、房间 IM 或区域 IM；客户端使用接口返回和关系列表刷新 UI。

## 9. App HTTP接口

所有接口走 `gateway-service`，响应使用 `{code,message,request_id,data}`。

| 方法 | 地址 | 说明 |
| --- | --- | --- |
| `GET` | `/api/v1/cp/applications` | 查询我的申请 |
| `POST` | `/api/v1/cp/applications/{application_id}/accept` | 同意申请 |
| `POST` | `/api/v1/cp/applications/{application_id}/reject` | 拒绝申请 |
| `GET` | `/api/v1/cp/relationships` | 查询我的 active 关系 |
| `POST` | `/api/v1/cp/relationships/{relationship_id}/break` | 解除关系并扣金币 |

`/api/v1/cp/relationships` 的 relationship item 包含：

| 字段 | 说明 |
| --- | --- |
| `relationship_id` | 关系 ID |
| `relation_type` | `cp`、`brother`、`sister` |
| `status` | 当前为 `active` |
| `me` | 当前用户快照 |
| `partner` | 对方用户快照 |
| `intimacy_value` | 当前亲密值 |
| `level` | 当前等级 |
| `current_level_threshold` | 当前等级起始亲密值 |
| `next_level_threshold` | 下一等级所需亲密值；满级时为 0 |
| `needed_for_next_level` | 距离下一级还需要多少亲密值；满级时为 0 |
| `level_progress_percent` | 当前等级内进度百分比，0 到 100 |
| `max_level` | 是否已经没有下一等级 |
| `breakup_cost_coins` | 当前关系解除需要支付的金币数 |
| `formed_at_ms` | 关系形成时间 |
| `ended_at_ms` | 解除时间；active 关系为 0 |
| `updated_at_ms` | 最近更新时间 |

这些字段由 user-service 从 `user_cp_level_rules` 计算，gateway 只做透传。客户端资料卡不能根据本地表推算这些值。

申请列表参数：

| 参数 | 类型 | 必填 | 说明 |
| --- | --- | --- | --- |
| `direction` | string | 否 | `incoming` 或 `outgoing`，默认 `incoming` |
| `status` | string | 否 | `pending`、`accepted`、`rejected`、`expired`、`blocked` |
| `page` | int32 | 否 | 默认 1 |
| `page_size` | int32 | 否 | 默认 20 |

关系列表参数：

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

拒绝请求体：

| 字段 | 类型 | 必填 | 说明 |
| --- | --- | --- | --- |
| `reason` | string | 否 | 拒绝原因，只进入 IM/outbox payload |

解除请求体：

| 字段 | 类型 | 必填 | 说明 |
| --- | --- | --- | --- |
| `command_id` | string | 是 | 解除关系幂等键，同一次重试必须复用 |

解除返回值：

| 字段 | 说明 |
| --- | --- |
| `relationship` | 已解除的关系，`status=ended` |
| `breakup_cost_coins` | 本次实际扣除金币数 |
| `wallet_transaction_id` | 钱包扣费交易 ID；费用为 0 时为空 |
| `coin_balance_after` | 扣费后金币余额；费用为 0 时为 0 |

## 10. 数据表

表放在 `user-service` 库，所有表带 `app_code`，时间使用 UTC epoch milliseconds。

| 表 | 说明 |
| --- | --- |
| `user_cp_relation_configs` | CP/兄弟/姐妹数量、申请过期和解除费用配置；CP 数量服务端固定为 1，兄弟/姐妹读取后台配置 |
| `user_cp_level_rules` | 等级阈值和奖励资源组 |
| `user_cp_gift_event_consumption` | `RoomGiftSent` 消费幂等 |
| `user_cp_applications` | CP 关系申请 |
| `user_cp_relationships` | 双人关系事实 |
| `user_cp_relationship_breakups` | 解除关系 pending/confirmed/canceled 占位和幂等事实 |
| `user_cp_level_reward_grants` | 等级奖励发放幂等 |
| `user_outbox` | CP 事件事实 |

关键唯一约束：

- active 关系按用户对唯一：`(app_code, user_a_id, user_b_id, status='active')`。
- CP active 关系按单用户唯一：任一用户已有 CP 关系就不能再接受新的 CP 关系。
- 兄弟/姐妹 active 关系按后台配置做单用户数量上限校验。
- pending 申请按用户对和类型唯一：`(app_code, requester_user_id, target_user_id, relation_type, status='pending')`。
- 解除关系按 `command_id` 幂等；同一关系同一时间只能有一条 pending 解除占位。

## 11. 相关IM

| IM | 通道 | 发送时机 |
| --- | --- | --- |
| `cp_application_created` | C2C | A 送 CP 礼物触发申请后，A 发给 B |
| `cp_application_created` | 房间群 | A 送 CP 礼物触发申请后，发到来源房间，payload 带 `recipient_user_id=B` |
| `cp_application_accepted` | C2C | B 同意后，B 发给 A，payload 带 `c2c_notice_role=accepted_to_requester`、`message_text=我同意了你` |
| `cp_application_accepted` | C2C | B 同意后，A 发给 B，payload 带 `c2c_notice_role=accepted_to_acceptor`、`message_text=对方已同意你的申请` |
| `cp_application_accepted` | 房间群 | B 同意后，发到来源房间，payload 带 `recipient_user_id=A` |
| `cp_application_rejected` | C2C | B 拒绝后，B 发给 A |
| `cp_application_rejected` | 房间群 | B 拒绝后，发到来源房间，payload 带 `recipient_user_id=A` |
| `cp_relationship_created` | 区域群 | 关系建立后发送区域播报 |
| `cp_relationship_capacity_reached` | 系统 C2C | A 送关系礼物后，A 或 B 的该类型 active 关系已达上限；管理员账号发给 A，payload 带 `message_text=用户关系已经达到上限` |
| 无 | 无 | 解除关系成功不发 IM，App 根据解除接口返回刷新 |

C2C 使用腾讯云 IM `From_Account` 表达会话方向：申请为 A，拒绝为 B；同意会发 `B -> A` 和 `A -> B` 两条。区域 IM 群 ID 使用 `hy_<app_code>_bc_r_v2_<region_id>`（v1 旧群 `hy_<app_code>_bc_r_<region_id>` 仅保留过渡期双发），本地测试可通过 `group_id_prefix` 加前缀。

## 12. 错误码

| 场景 | code | 说明 |
| --- | --- | --- |
| 申请不存在 | `NOT_FOUND` | application_id 不存在或不属于当前用户 |
| 申请已过期 | `CONFLICT` | 不能再同意；内部 gRPC 语义是 FailedPrecondition |
| 已有关系 | `CONFLICT` | A/B 任一方已经有 active 关系 |
| 无权限处理 | `PERMISSION_DENIED` | 不是申请目标用户 |
| 余额不足 | `INSUFFICIENT_BALANCE` | 解除关系扣金币失败 |
| 关系不可解除 | `CONFLICT` | 关系不是 active，或已有 pending 解除 |
| 幂等冲突 | `CONFLICT` | command_id 或事件消费 payload 不一致 |

## 13. 验证项

- CP 礼物发送成功后，`RoomGiftSent` 带 `gift_type_code=cp`、`cp_relation_type`、礼物 icon 快照。
- A 给 B 连发 `cp`、`brother`、`sister` 三种礼物，会生成三条 pending 申请。
- B 接受其中一条后，其他两条不能再接受。
- A/B 建立关系后，A 或 B 都不能再和第三人建立 active 关系。
- pending 申请超过默认 24 小时后不能接受。
- active 关系双方互送任意礼物，亲密值按 `heat_value` 增加。
- active 关系解除会扣除后台配置的金币费用，扣费失败不改变关系状态。
- 同意、拒绝、关系成立的 IM 都写入 `notice_delivery_events`，失败可重试。
- 关系容量已满后再次送关系礼物，会写 `relationship_capacity_reached` 消费位点，不生成 pending 申请，并把 `cp_relationship_capacity_reached` 系统 IM 投递给送礼人。
