# 房间置顶与房间列表排序产品开发文档 ## 目标 后台在「房间管理」下增加二级菜单「房间置顶」,用于运营配置公共房间发现页的置顶顺序。 置顶只影响 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 建临时库。 - 该验证不连接线上库,不影响线上数据。