# 房间置顶与房间列表排序产品开发文档

## 目标

后台在「房间管理」下增加二级菜单「房间置顶」，用于运营配置公共房间发现页的置顶顺序。

置顶只影响 App 房间发现列表排序，不改变房间状态、不改变房间区域、不影响进房权限。

## 后台页面

菜单：房间管理 / 房间置顶

列表展示：

| 字段 | 说明 |
| --- | --- |
| 房间信息 | 房间头像、房间名、房间长 ID、房间短 ID |
| 房主信息 | 房主头像、昵称、用户长 ID、用户短 ID |
| 区域 | 房间当前区域 |
| 置顶时间 | `pinnedAtMs` 到 `expiresAtMs` |
| 排序 | `weight`，数值越大越靠前 |
| 置顶类型 | `global` 全区置顶；`region` 区域置顶 |
| 状态 | `active` 生效中；`expired` 已过期；`cancelled` 已取消 |

新增置顶流程：

1. 输入用户短 ID 或房间 ID 搜索房间。
2. 选择置顶类型：全区置顶或区域置顶。
3. 选择置顶开始时间和结束时间。
4. 填写排序值，数值越大越靠前。
5. 点击置顶后，后台创建置顶记录。

区域规则：

- 全区置顶对所有用户国家/区域生效。
- 区域置顶只对房间当前区域生效，不需要后台单独选择区域。
- 房间后续改区域时，置顶记录不会自动改区域；后台可以取消旧置顶后重新置顶。

过期规则：

- 当前时间不在 `[pinnedAtMs, expiresAtMs)` 内时，App 列表不再把该房间当置顶房间。
- 过期后房间自动恢复普通房间排序，不需要修改房间状态。
- 后台列表读取时会把已过期的 active 记录展示为 `expired`。

## App 房间列表排序

地址：`GET /api/v1/rooms`

参数：

| 参数 | 说明 |
| --- | --- |
| `tab` | `hot` 或 `new` |
| `limit` | 每页数量 |
| `cursor` | 下一页游标 |
| `q` | 搜索关键词，可不传 |

区域来源：

- gateway 使用登录用户 ID 调 user-service 获取 `users.region_id`。
- 客户端传的区域参数不会生效。
- room-service 按该用户区域做排序分组。

`hot` 排序：

1. 全区置顶房间，多个房间按 `weight DESC`。
2. 当前用户区域的置顶房间，多个房间按 `weight DESC`。
3. 当前用户区域有人普通房，按 `hot_score DESC`。
4. 其他区域有人普通房，按 `hot_score DESC`。
5. 当前用户区域空房，按 `hot_score DESC`。
6. 其他区域空房，按 `hot_score DESC`。

`hot_score` 规则：

```text
heat_score = log2(heat + 1)
online_score = log2(min(online_count, 50) + 1)
mic_score = min(occupied_seat_count, 10)

hot_score = heat_score * 650000
          + online_score * 300000
          + mic_score * 50000
```

说明：

- `heat` 是房间贡献值。
- `online_count = 0` 的普通房进入空房分组；只要存在有人普通房，空房不会排在有人普通房前面。
- 在线人数最多按 50 人计分，防止单个房间靠人数无限放大。
- 上麦人数最多按 10 人计分，只作为活跃气氛补充。

`new` 排序：

1. 全区置顶房间，多个房间按 `weight DESC`。
2. 当前用户区域的置顶房间，多个房间按 `weight DESC`。
3. 当前用户区域的普通房间，按创建时间 `created_at_ms DESC`。
4. 其他区域的普通房间，按创建时间 `created_at_ms DESC`。

同一个排序分组内，如果排序值相同，后端使用过期时间和房间 ID 保证分页稳定。

返回值：

```json
{
  "code": 0,
  "message": "ok",
  "request_id": "request-id",
  "data": {
    "rooms": [
      {
        "room_id": "room-global-pin",
        "room_short_id": "163212",
        "owner_user_id": "165059",
        "title": "room name",
        "cover_url": "https://example.com/room.png",
        "visible_region_id": 2,
        "heat": 1000,
        "online_count": 0,
        "locked": false
      }
    ],
    "next_cursor": ""
  }
}
```

相关 IM：

- 无新增 IM。
- 置顶只影响 HTTP 房间列表读模型，不发送房间群消息或用户私信。

## 房间列表在线人数更新逻辑

房间列表返回的 `online_count` 来自 room-service 的业务 presence，不直接读取腾讯 IM 在线人数，也不直接读取 RTC 在线人数。

更新入口：

| 入口 | 说明 | 列表人数变化 |
| --- | --- | --- |
| `POST /api/v1/rooms/join` | 用户进房，room-service 把用户写入 Room Cell 的 `OnlineUsers` | 增加或保持不变 |
| `POST /api/v1/rooms/heartbeat` | 用户在房间内刷新 `LastSeenAtMS` | 人数不变，只延长在线有效期 |
| `POST /api/v1/rooms/leave` | 用户主动离房，room-service 从 `OnlineUsers` 删除用户 | 减少 |
| 踢人、关房、系统驱逐 | 房间命令链路清理 presence | 减少或清零 |
| stale worker | 用户断线、杀进程、没有主动离房时，由后台扫描清理超时 presence | 延迟减少 |

投影规则：

- 每次 Room Cell 命令成功提交后，room-service 用最新快照刷新 `room_list_entries`。
- `room_list_entries.online_count = len(snapshot.online_users)`。
- `room_list_entries.occupied_seat_count` 来自当前被占用麦位数。
- `hot_score` 使用 `online_count` 和 `occupied_seat_count`，所以在线人数变化会影响 hot 普通房排序。
- 列表投影是最终一致读模型；投影失败只记录日志，不回滚已经成功的房间命令。

延迟：

| 场景 | 预计延迟 |
| --- | --- |
| 正常进房 | 同一次 `JoinRoom` 命令内刷新，通常是毫秒级到几十毫秒 |
| 主动离房 | 同一次 `LeaveRoom` 命令内刷新，通常是毫秒级到几十毫秒 |
| 踢人、关房、系统驱逐 | 同一次房间命令内刷新，通常是毫秒级到几十毫秒 |
| 心跳 | 只刷新有效期，不改变列表人数 |
| App 断线或杀进程且没有主动离房 | 当前配置 `presence_stale_after = 2m`，`presence_stale_scan_interval = 30s`，通常约 2 分钟到 2 分 30 秒后列表人数减少 |

相关 IM：

- 无新增 IM。
- 在线人数是 room-service 业务 presence，不等于腾讯 IM 长连接在线人数。
- 腾讯 IM 退群、RTC 退房不会直接删除 room-service presence；业务离房只能由 `LeaveRoom`、踢人、关房、系统驱逐或 stale worker 完成。

## 后台接口

后台接口使用后台登录 token 和 `/api/v1` 前缀。

### 查询置顶列表

地址：`GET /api/v1/admin/rooms/pins`

参数：

| 参数 | 说明 |
| --- | --- |
| `page` | 页码，默认 1 |
| `page_size` | 每页数量，默认 20，最大 100 |
| `keyword` | 房间 ID、房间短 ID、房间名关键词 |
| `status` | `active`、`expired`、`cancelled`、`all`，默认 `active` |
| `pinType` | `global` 或 `region`，不传表示全部类型 |
| `regionId` | 区域筛选，不传表示全部区域 |

返回值：

```json
{
  "code": 0,
  "message": "ok",
  "data": {
    "items": [
      {
        "id": 1,
        "pinType": "global",
        "status": "active",
        "weight": 100,
        "pinnedAtMs": 1780665600000,
        "expiresAtMs": 1783257600000,
        "remainingMs": 2592000000,
        "room": {
          "roomId": "room-global-pin",
          "roomShortId": "163212",
          "title": "room name",
          "visibleRegionId": 2,
          "regionName": "南亚区"
        },
        "user": {
          "userId": "165059",
          "displayUserId": "165059",
          "username": "host name"
        }
      }
    ],
    "page": 1,
    "pageSize": 20,
    "total": 1
  }
}
```

### 搜索房间

地址：`GET /api/v1/admin/rooms`

参数：

| 参数 | 说明 |
| --- | --- |
| `keyword` | 用户短 ID、房间短 ID、房间长 ID、房间名 |
| `status` | 建议新增置顶时传 `active` |
| `page` | 页码 |
| `page_size` | 每页数量 |

说明：

- 输入用户短 ID 时，后台会先查用户，再按房主用户 ID 精确查房间。
- 输入房间 ID 时，后台直接按 room-service 房间列表查询。

### 新增置顶

地址：`POST /api/v1/admin/rooms/pins`

参数：

| 参数 | 说明 |
| --- | --- |
| `roomId` | 房间长 ID 或房间短 ID |
| `pinType` | `global` 全区置顶；`region` 区域置顶；不传默认 `region` |
| `weight` | 排序值，不能小于 0 |
| `pinnedAtMs` | 置顶开始时间，Unix 毫秒 |
| `expiresAtMs` | 置顶结束时间，Unix 毫秒 |
| `durationDays` | 旧字段兼容；新后台优先传开始和结束时间 |

请求示例：

```json
{
  "roomId": "room-global-pin",
  "pinType": "global",
  "weight": 100,
  "pinnedAtMs": 1780665600000,
  "expiresAtMs": 1783257600000
}
```

返回值：返回创建后的置顶记录，结构同列表单条记录。

### 取消置顶

地址：`DELETE /api/v1/admin/rooms/pins/{pin_id}`

参数：

| 参数 | 说明 |
| --- | --- |
| `pin_id` | 置顶记录 ID |

返回值：返回取消后的置顶记录，状态为 `cancelled`。

相关 IM：

- 无新增 IM。
- 新增、取消、过期置顶都只影响发现页排序。

## 数据规则

- `room_region_pins.pin_type = global` 表示全区置顶，落库 `visible_region_id = 0`。
- `room_region_pins.pin_type = region` 表示区域置顶，落库 `visible_region_id = 房间当前 visible_region_id`。
- 有效置顶条件：`status = active` 且 `pinned_at_ms <= now_ms` 且 `expires_at_ms > now_ms`。
- 同一个房间可以同时有全区置顶和区域置顶；App 列表优先按全区置顶分组展示。
- `hot` 普通房先按本区域/其他区域和有人/空房分组，再按 `hot_score` 排序。
- `new` 普通房只按本区域/其他区域分组，再按创建时间排序。
- 置顶不进入 Room Cell、snapshot 或 command log；它是发现页运营排序读模型。
- 房间核心状态仍由 room-service 管理，后台不直接写房间库。

## 本地真实数据验证

验证环境：

- 本地 Docker MySQL：`hyapp-mysql`，端口 `23306`，状态 healthy。
- 用真实 MySQL 临时库执行 room-service 用例，不使用 memory repository。

验证命令：

```bash
ROOM_SERVICE_MYSQL_TEST_DSN='root:root@tcp(127.0.0.1:23306)/?parseTime=true&loc=UTC&multiStatements=true' \
go test ./services/room-service/internal/room/service \
  -run 'TestRoomListSortScore|TestPinnedRoomListOrdersGlobalRegionLocalThenOtherFor(Hot|New)|TestRegionalPinOrdersPublicRoomList' \
  -count=1 -v
```

验证结果：

- `TestRegionalPinOrdersPublicRoomList` 通过，验证区域置顶优先于同区域普通 hot 房间。
- `TestPinnedRoomListOrdersGlobalRegionLocalThenOtherForHot` 通过，验证 hot 顺序为全区置顶、当前区域置顶、当前区域有人普通房、其他区域有人普通房、当前区域空房、其他区域空房。
- `TestPinnedRoomListOrdersGlobalRegionLocalThenOtherForNew` 通过，验证 new 顺序为全区置顶、当前区域置顶、当前区域普通房、其他区域普通房。
- `TestRoomListSortScoreUsesCompressedWeightedInputs` 通过，验证 `hot_score` 使用 65%、30%、5% 的压缩权重。
- `TestRoomListSortScoreCapsPresenceInputs` 通过，验证在线人数和上麦人数有计分上限。
- 测试过程创建了真实房间数据：`room-global-pin`、`room-region-pin`、`room-local-online`、`room-other-online`、`room-local-empty`、`room-other-empty`、`room-global-new`、`room-region-new` 等。

补充说明：

- 普通 `hyapp` MySQL 用户没有创建临时库权限，所以本地真实数据验证使用 root DSN 建临时库。
- 该验证不连接线上库，不影响线上数据。
