# 送礼连击 V2 协议

## 边界

V2 只改变客户端封批、可靠重试和结算结果重放，不改变房间状态 owner：

- Flutter 立即播放连击反馈，第一批立即请求，后续点击按远程窗口聚合。
- HTTP 仍是账务入口；`room-service` 的 Room Cell 串行提交房间状态。
- `wallet-service` 负责扣费，`lucky-gift-service` 负责逐份独立抽奖，`room-service` 只编排持久化 Saga。
- V1 路由和顶层响应字段只为已发布的旧客户端保留；当前 Flutter 固定调用 V2，不做 V1 降级。

## HTTP 契约

新客户端调用：

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

请求沿用 V1 字段，并要求：

```json
{
  "room_id": "room-id",
  "command_id": "stable-combo-session_b1",
  "combo_session_id": "stable-combo-session",
  "batch_seq": 1,
  "target_type": "user",
  "target_user_ids": [1001, 1002],
  "gift_id": "gift-id",
  "gift_count": 20,
  "pool_id": "optional-lucky-pool"
}
```

`command_id` 是唯一财务幂等键。超时、429 和临时 5xx 必须复用同一请求体与同一 `command_id`；`combo_session_id + batch_seq` 只用于客户端批次顺序和审计。当前 Flutter 不在 404 或旧响应下改调 V1，发布前必须先保证 V2 路由可用。

响应继续返回 V1 顶层的 `billing_receipt_id`、`coin_balance_after`、`lucky_gift(s)`、`batch_display` 等字段，同时增加 `v2`：

```json
{
  "billing_receipt_id": "legacy-compatible-receipt",
  "coin_balance_after": 9000,
  "v2": {
    "api_version": 2,
    "command_id": "stable-combo-session_b1",
    "combo_session_id": "stable-combo-session",
    "batch_seq": 1,
    "idempotent_replay": false,
    "committed_room_version": 123,
    "billing_receipt_id": "legacy-compatible-receipt",
    "coin_balance_after": 9000,
    "balance_version": 456,
    "settled_gift_count": 20,
    "settled_target_user_ids": [1001, 1002],
    "lucky_hits": [],
    "operation_status": "committed"
  }
}
```

相同 `command_id` 的重试返回首次提交的同一账务回执、余额版本、实际目标、幸运结果和批次展示，仅把 `idempotent_replay` 置为 `true`。历史 V1 command log 没有 typed 结果时保持原有 snapshot-only 行为，不伪造结算结果。

## 客户端封批规则

礼物面板响应的 `combo_config` 控制客户端策略：

- `enabled`：紧急关闭微批；关闭后仍调用 V2，只是不再聚合连续点击。
- `api_version`：服务端契约版本标记，当前 Flutter 只接受 V2，不用它选择 V1 入口。
- `window_ms`：后续点击固定封批窗口，默认 300ms。
- `max_gift_count_per_batch`：单目标数量上限，最大 999。
- `max_gift_units_per_batch`：`gift_count * 去重收礼人数` 上限，最大 5000。
- `max_in_flight`：最多 2 个 HTTP 批次在途。
- `max_pending_batches`：本地有界队列上限。
- `retry_max_attempts`、`retry_base_delay_ms`：可靠重试上限和指数退避基数；429 优先遵守 `Retry-After`。
- `rollout_basis_points`：按 `app_code + user_id` 稳定分桶，0 到 10000；只灰度微批策略，不改变 V2 HTTP 入口。

后台通过以下 app-scoped 接口读取和全量替换配置：

```text
GET /admin/app-config/gift-combo
PUT /admin/app-config/gift-combo
```

服务端在 gateway、room、wallet、lucky 四层都强制校验 `gift_count <= 999`、总 gift units `<= 5000` 和幂等键长度。远程配置只能收紧客户端行为，不能提高 owner 上限。

## 上线顺序

1. 先执行 `services/room-service/deploy/mysql/migrations/002_gift_operation_v2.sql`，再滚动发布 protobuf、wallet、lucky、room、gateway，确认 V2 可用后发布当前 Flutter；已发布的旧 Flutter 继续走服务端 V1。
2. 首次上线把 `rollout_basis_points` 设为 0，验证 V2 单批发送、重复 `command_id` 重放和 Gift Saga 恢复指标；该值不会让当前 Flutter 回退 V1。
3. 按 App 从小流量提高微批灰度；异常时把 `enabled=false` 或灰度归零，客户端仍使用 V2 单批发送。
4. MQ 先保持 `wallet_event_tag_mode=legacy`、`room_event_tag_mode=legacy`。确认所有兼容 consumer 已上线并排空存量后，再分别切为 `event_type`；不要在同一批发布中同时切 producer 和删除 legacy selector。

数据库迁移、服务发布、Flutter 微批灰度和 MQ Tag 切换是四个独立开关。任何一步都不能用“客户端已限流”替代 owner 幂等、Saga 恢复或 Broker 侧过滤。
