# Room Cell State Machines

本文档定义 `room-service` 在微服务多机部署下必须遵守的 Room Cell 状态机。Room Cell 的目标是让同一个房间在同一时间只有一个执行节点提交状态，并且在节点重启、发布、RTC 回调延迟和 outbox 重试时不丢状态、不双写、不重复扣费。

## Room

Room 是房间生命周期的主状态。只有 `active` 房间允许进房、麦位、管理和送礼命令。

```mermaid
stateDiagram-v2
  [*] --> creating
  creating --> active: CreateRoom 持久化成功
  active --> closing: CloseRoom 开始收尾
  closing --> closed: command log/outbox/meta 提交
  active --> closed: CloseRoom 当前实现一次提交完成
  closed --> [*]
```

状态语义：

| State      | Meaning                                    |
| ---------- | ------------------------------------------ |
| `creating` | 正在创建房间外部依赖，不对客户端开放       |
| `active`   | 可进房、可管理、可上麦、可送礼             |
| `closing`  | 正在关闭，不再接收新业务命令               |
| `closed`   | 已关闭，`JoinRoom` 和 guard 查询都必须拒绝 |

`CloseRoom` 必须作为 Room Cell 命令提交，不能只从后台直接更新 `rooms.status`。关闭房间时必须清空业务 presence、释放麦位、写 command log、写 `RoomClosed` outbox、更新 `rooms` 和 `room_list_entries` 状态。

## Presence

Presence 是 room-service 自己维护的业务进房状态，不等同于腾讯云 IM 或 RTC 的真实连接。

```mermaid
stateDiagram-v2
  [*] --> absent
  absent --> present: JoinRoom
  present --> present: JoinRoom 重试或 RoomHeartbeat 刷新 last_seen_at_ms
  present --> left: LeaveRoom
  present --> stale_left: stale worker 超时清理
  present --> kicked: KickUser
  present --> closed_left: CloseRoom
  left --> present: JoinRoom
  stale_left --> present: JoinRoom
  kicked --> present: UnbanUser 后重新 JoinRoom
  closed_left --> [*]
```

规则：

- `RoomHeartbeat` 只能刷新已有 presence，不能创建 presence。
- `LeaveRoom` 要同步释放用户占用的麦位。
- RTC `room_exited` 只释放当前麦位，不清理业务 presence；用户可能仍停留在房间页等待重连。
- `KickUser` 写入 ban 集合，用户必须先 `UnbanUser` 才能重新 `JoinRoom`。

## Mic

Mic 是麦位和 RTC 发流确认状态。业务上麦不等于已经在 RTC 发流。

```mermaid
stateDiagram-v2
  [*] --> idle
  idle --> locked: SetMicSeatLock(true)
  locked --> idle: SetMicSeatLock(false)
  idle --> pending_publish: MicUp
  pending_publish --> publishing: ConfirmMicPublishing 或 RTC audio_started
  pending_publish --> idle: publish_timeout 或 MicDown 或 LeaveRoom
  publishing --> idle: MicDown 或 RTC audio_stopped 或 LeaveRoom
  publishing --> pending_publish: ChangeMicSeat 保留 mic_session
  pending_publish --> pending_publish: ChangeMicSeat 保留 mic_session
```

规则：

- `mic_session_id` 是单次上麦会话 ID，旧会话的确认、停止和超时任务不能清掉新会话。
- `mic_session_room_version` 和 `last_publish_event_time_ms` 用于丢弃旧 RTC 事件。
- `pending_publish` 超过 `publish_deadline_ms` 后由 worker 自动下麦。
- `LeaveRoom` 与 RTC `room_exited` 并发时以业务 presence 的 `last_seen_at_ms` 和当前 `mic_session_id` 为准；旧的 RTC 退房事件不能释放用户重进房后的新麦位会话。
- `CloseRoom` 必须释放全部麦位，但保留麦位编号和锁定结构。

## Lease

Lease 是多机部署下的房间执行权。Redis 只保存热路由，不是恢复来源；MySQL snapshot + command log 才是恢复来源。

```mermaid
stateDiagram-v2
  [*] --> unowned
  unowned --> owned_by_node: EnsureOwner
  owned_by_node --> owned_by_node: 同节点续约并生成新 lease_token
  owned_by_node --> expired: TTL 到期
  expired --> owned_by_other_node: 新节点 EnsureOwner
  owned_by_node --> fenced_out: 提交前 VerifyOwner 失败
  fenced_out --> [*]
```

规则：

- Redis runtime route key 统一使用 `app_code + "\0" + room_id`，包括默认 App；不能再使用裸 `room_id` 作为兼容 key。
- `room:node:{node_id}` 保存当前 room-service 实例的 `advertise_addr`，只用于 owner 转发；它不是服务发现的全局配置中心。
- gateway 可以连接 room-service 内网负载均衡；命中非 owner 节点时，gRPC 边界必须按 Redis owner route 转发到当前 owner 实例。
- `advertise_addr` 必须是实例私网地址，不能是 room-service 内网 CLB 地址，否则转发会命中任意节点并触发递归保护。
- 命令开始前通过 `EnsureOwner` 获得执行权。
- 命令真正写 command log/outbox 前必须用 `lease_token` 再做一次 `VerifyOwner`。
- 如果提交前 lease 已经过期或 token 已变化，旧节点必须返回 `UNAVAILABLE`，不能写房间状态。
- `room_command_log` 保存 `owner_node_id` 和 `lease_token`，用于排查谁在什么 lease 下提交了命令。
- stale presence worker 和 mic publish timeout worker 只能扫描本节点已加载 Cell；清理前必须确认当前 Redis lease 仍属于本节点，并在读完快照后再次用相同 `lease_token` 校验，最后由命令提交链路再做 fencing。
- 节点进入 draining 后不能再接收新的 Room Cell 命令，也不能恢复或创建新的 Cell；gRPC drain 完成后应主动释放本节点已加载房间的 owner lease，降低滚动发布等待 TTL 的窗口。

## Outbox

Outbox 是房间事件对外投递状态机。它允许重复投递，但多 worker 不应该同时抢同一批。

```mermaid
stateDiagram-v2
  [*] --> pending
  pending --> delivering: ClaimPendingOutbox
  retryable --> delivering: ClaimPendingOutbox
  delivering --> delivered: 投递成功并确认
  delivering --> retryable: 投递失败或状态未知
  delivering --> failed: 明确不可重试
  retryable --> failed: 超过策略上限或人工判定
```

规则：

- `ClaimPendingOutbox` 必须把事件从 `pending/retryable` 原子改成 `delivering`，并写入 `worker_id` 和 `lock_until_ms`。
- 其他 worker 不能抢到未过期的 `delivering` 事件。
- 投递成功后才能标记 `delivered`。
- 投递失败必须保留 envelope 和 last_error，转入 `retryable` 等待下一轮抢占。
- outbox 事件必须带 `app_code`，消费者按 App 隔离幂等。
