# Room Outbox Worker

本文档定义当前 `room-service` outbox 的实际语义。房间写命令只负责提交 Room Cell 状态、command log、snapshot 和 `room_outbox`；腾讯云 IM、activity-service、notice-service、后续 push/inbox 等外部副作用都通过 outbox worker 或 RocketMQ 下游 consumer 异步完成。

## 核心结论

| 项目 | 当前规则 |
| --- | --- |
| 状态 owner | Room Cell 和 MySQL snapshot/command log 是房间状态权威来源 |
| 持久事实 | `room_outbox` 是房间外事件的持久事实源，和房间命令提交链路一起落 MySQL |
| 发布模式 | `outbox_worker.publish_mode` 支持 `direct`、`mq`、`dual` |
| MQ 职责 | RocketMQ 只做事件分发和火箭延迟唤醒，不保存房间核心状态 |
| 下游语义 | 下游服务按 `event_id` 幂等消费，失败由自己的位点、重试和死信处理 |
| 主链路 | HTTP/gRPC 请求不等待 IM REST、activity、notice、push 或 inbox 投递完成 |

不做的事：

| 不做 | 原因 |
| --- | --- |
| 在 Room Cell 中保存投递状态 | 投递状态属于 outbox 或下游消费位点，不属于房间权威状态 |
| 用 Redis 替代 outbox | Redis 只保存热状态和短 TTL lease，不能作为恢复事实来源 |
| 用 RocketMQ 替代 `room_outbox` | 房间状态提交成功但 MQ 发布失败时，必须能从 MySQL 补偿发布 |
| 追求精确一次投递 | 外部系统和进程崩溃窗口只能做到 at-least-once，消费者必须按 `event_id` 去重 |

## 写命令链路

```text
gateway -> room-service command
  -> command_id idempotency check
  -> ensure Redis lease and Room Cell
  -> mutate cloned RoomState
  -> optional wallet debit before room gift state is committed
  -> SaveMutation(command log + room_outbox + room meta)
  -> snapshot when version hits snapshot_every_n
  -> update list/presence projections best effort
  -> return response

outbox worker
  -> claim pending/retryable rows
  -> if RoomRocketIgnited, schedule rocket launch delayed wakeup when configured
  -> publish by mode:
       direct: Tencent IM publisher + activity gRPC publisher
       mq:     RocketMQ room_outbox publisher
       dual:   direct publishers + RocketMQ publisher
  -> delivered / retryable / failed
```

主请求成功只表示房间状态已经提交；不表示腾讯云 IM、activity-service、notice-service 或任何后续通知已经投递完成。客户端实时表现应以请求响应、房间 snapshot 和腾讯云 IM 系统消息共同补偿。

## 发布模式

| `publish_mode` | 行为 | 适用场景 |
| --- | --- | --- |
| `direct` | worker 直接调用腾讯云 IM publisher 和 activity gRPC publisher；所有 direct publisher 成功后才标记 `delivered` | 本地开发、未接 MQ 的隔离测试 |
| `mq` | worker 只把 `room_outbox_event` 发布到 `hyapp_room_outbox`；RocketMQ 接收成功后标记 `delivered` | 线上推荐模式，activity、notice、IM bridge 各自 consumer group 解耦消费 |
| `dual` | 同时 direct 投递和 MQ 发布，全部成功后标记 `delivered` | 迁移或灰度期；要求所有消费端都能按 `event_id` 去重 |

在 `mq` 模式下，`room_outbox.status=delivered` 的含义是“事件已经可靠进入 RocketMQ”，不是“所有下游都已处理成功”。下游的成功、重试和死信由各自服务的消费位点负责，不能反写 `room_outbox.status`。

## 数据状态

`room_outbox.status` 使用：

| 状态 | 含义 |
| --- | --- |
| `pending` | 已落库，尚未被 worker 处理 |
| `delivering` | 已被 worker 抢占，`lock_until_ms` 前其他 worker 不抢 |
| `delivered` | 当前发布模式要求的 publisher 已处理成功；MQ 模式下表示 RocketMQ 已接收 |
| `retryable` | 上次发布失败，等待 `next_retry_at_ms` 后重试 |
| `failed` | 达到最大重试次数，进入死信，需要后台工具查看或重放 |

旧数据里可能存在历史 `published` 状态。当前 worker 不再写这个状态，新链路只写上表状态。

## RocketMQ Topics

| Topic | Producer | Consumer Group | 语义 |
| --- | --- | --- | --- |
| `hyapp_room_outbox` | `room-service` outbox worker | `hyapp-activity-room-outbox` | activity-service 消费房间事实，做活动、播报、系统消息等后续处理 |
| `hyapp_room_outbox` | `room-service` outbox worker | `hyapp-notice-room-outbox` | notice-service 消费私有通知相关事实，例如 `RoomUserKicked` |
| `hyapp_room_outbox` | `room-service` outbox worker | `hyapp-room-im-bridge` | room-service IM bridge 消费房间群系统消息和群成员控制事实 |
| `hyapp_room_rocket_launch` | `room-service` rocket scheduler | `hyapp-room-rocket-open` | 火箭倒计时到点唤醒 Room Cell 发射命令 |

`hyapp_room_rocket_launch` 不是业务状态源。消息只携带 `app_code、room_id、rocket_id、level、launch_at_ms、command_id` 等唤醒参数；真正能否发射仍由 room-service 加载 Room Cell 后校验当前火箭状态、等级、`rocket_id`、`launch_at_ms` 和 UTC 重置边界。

## 配置

`services/room-service/configs/*.yaml` 需要同时维护本地、Docker 和腾讯云示例配置：

```yaml
rocketmq:
  enabled: false
  name_servers: []
  name_server_domain: ""
  access_key: ""
  secret_key: ""
  namespace: ""
  send_timeout: "3s"
  retry: 2
  # 安全默认值；全部 room consumer 完成 legacy||typed 兼容订阅后才切 event_type。
  room_event_tag_mode: "legacy"
  room_outbox:
    enabled: false
    topic: "hyapp_room_outbox"
    producer_group: "hyapp-room-outbox-producer"
    tencent_im_consumer_enabled: false
    tencent_im_consumer_group: "hyapp-room-im-bridge"
    consumer_max_reconsume_times: 16
  rocket_launch:
    enabled: false
    topic: "hyapp_room_rocket_launch"
    producer_group: "hyapp-room-rocket-open-producer"
    consumer_group: "hyapp-room-rocket-open"
    consumer_max_reconsume_times: 16

outbox_worker:
  enabled: true
  publish_mode: "direct"
  poll_interval: "1s"
  batch_size: 100
  publish_timeout: "3s"
  retry_strategy: "exponential_backoff"
  max_retry_count: 10
  initial_backoff: "5s"
  max_backoff: "5m"
```

配置规则：

| 字段 | 规则 |
| --- | --- |
| `rocketmq.enabled` | 控制 RocketMQ client 是否启用；本地默认关闭，线上示例开启 |
| `rocketmq.room_event_tag_mode` | `legacy` 是无损迁移默认值；`event_type` 只在所有 consumer 已双订阅后显式开启 |
| `rocketmq.room_outbox.enabled` | 控制 room outbox 是否可发布到 MQ；`publish_mode=mq/dual` 时必须开启 |
| `rocketmq.room_outbox.tencent_im_consumer_enabled` | 控制 room-service 是否作为 IM bridge consumer 消费 `hyapp_room_outbox` |
| `rocketmq.rocket_launch.enabled` | 控制火箭点火后是否发布和消费延迟发射唤醒消息 |
| `outbox_worker.publish_mode` | 本地默认 `direct`，线上建议 `mq`，迁移期可用 `dual` |
| `poll_interval` | worker 空轮询间隔，必须大于 0，避免 busy loop |
| `batch_size` | 每轮最多处理条数；同步 publisher 逐条 claim，避免批尾尚未发送 lease 就已过期 |
| `publish_timeout` | 单条 outbox 发布 deadline，避免外部系统拖死 worker |
| `max_retry_count` | 达到次数后标记 `failed`，防止 poison event 永久刷日志 |

## 投递对象

| Publisher / Consumer | 行为 |
| --- | --- |
| RocketMQ room outbox publisher | 把 protobuf outbox 包装成统一 `room_outbox_event`，发布到 `hyapp_room_outbox` |
| Tencent IM direct publisher | `RoomCreated` 对已同步创建的房间群执行幂等 EnsureGroup；房间系统事件发送腾讯云 IM 群自定义消息 |
| Activity direct publisher | 本地或未接 MQ 时直接调用 activity-service room event 消费入口 |
| Rocket launch scheduler | 在 `RoomRocketIgnited` 后发布到点延迟消息，唤醒 room-service 发射 |
| Activity MQ consumer | 独立 consumer group 消费 `hyapp_room_outbox`，按 `event_id` 幂等推进活动/播报逻辑 |
| Notice MQ consumer | 独立 consumer group 消费 `hyapp_room_outbox`，写 `notice_delivery_events` 后投 C2C 私有通知 |
| Room IM bridge MQ consumer | 独立 consumer group 消费 `hyapp_room_outbox`，投递房间群系统消息和群成员控制 |

同一个事件允许被多个 consumer group 消费。任何 consumer 失败都不能阻塞 Room Cell 主链路，也不能影响其它 consumer group 的进度。

## 重试和死信

发布失败处理：

```text
publish failed
  -> retry_count + 1
  -> if retry_count >= max_retry_count: status=failed
  -> else: status=retryable, next_retry_at_ms=now+backoff
```

`failed` 不再自动重试。后台应提供人工查看和重放工具，重放时必须保留原 `event_id` 或生成明确的新修复事件，避免消费端无法去重。

RocketMQ consumer 失败时不修改 `room_outbox`。consumer 应依赖 RocketMQ 重投、自己的幂等表和死信处理；对于已经执行业务副作用但 ACK 失败的窗口，下一次消费必须按 `event_id` 安全去重。

## 阶段耗时日志

每条房间写命令都会输出 `room_command_timing`：

| 字段 | 含义 |
| --- | --- |
| `idempotency_ms` | command log 幂等检查耗时 |
| `lease_ms` | Redis lease / Room Cell 装载耗时 |
| `wallet_debit_ms` | 送礼扣费耗时；非送礼为 0 |
| `save_mutation_ms` | command log + outbox + 房间 meta 提交耗时 |
| `snapshot_ms` | 快照持久化耗时；未触发为 0 |
| `projection_ms` | 列表和 presence 投影耗时 |
| `activity_ms` | 主链路 activity 同步耗时；当前固定为 0 |
| `tencent_im_ms` | 主链路腾讯 IM 同步耗时；CreateRoom 记录同步建群耗时，其余命令当前为 0 |
| `activity_async_outbox` | 本命令是否产生异步 activity/outbox 投递 |
| `tencent_im_async_outbox` | 本命令是否产生异步 IM/outbox 投递 |

排查接口卡顿时先看 `total_ms`，再看各阶段字段。送礼慢通常优先看 `wallet_debit_ms`，普通房间命令慢优先看 `save_mutation_ms`、`snapshot_ms` 和 `projection_ms`。

## 验收

| 场景 | 验收 |
| --- | --- |
| IM 不可用，`direct/dual` | 房间写命令仍成功，outbox 进入 `retryable` 或最终 `failed` |
| activity 不可用，`direct/dual` | 房间写命令仍成功，outbox 进入重试 |
| MQ 不可用，`mq/dual` | 房间写命令仍成功，outbox 进入重试；MQ 恢复后补发 |
| activity consumer 不可用，`mq` | `room_outbox` 不回退；RocketMQ 和 activity 消费位点负责积压与重试 |
| poison event | 达到 `max_retry_count` 后进入 `failed`，不再每秒刷日志 |
| 火箭倒计时 | `RoomRocketIgnited` 发布延迟唤醒；到点后 room-service 重新校验状态再发射 |
| 正常投递 | 同一 `event_id` 可被多个 consumer group 独立消费且重复投递不产生重复副作用 |
| 真实链路 | create/join/mic/sendGift/leave 均有 `room_command_timing` |
