2026-04-30 02:30:32 +08:00

234 lines
7.6 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package service
import (
"context"
"encoding/base64"
"encoding/json"
"log"
"strings"
roomv1 "hyapp/api/proto/room/v1"
"hyapp/pkg/xerr"
)
const (
roomListTabHot = "hot"
roomListTabNew = "new"
defaultRoomListLimit = 20
maxRoomListLimit = 50
)
// roomListCursor 是服务端生成的不透明翻页游标。
// 客户端只能原样传回不能依赖内部字段tab 写入游标用于拒绝跨 tab 复用。
type roomListCursor struct {
Tab string `json:"tab"`
SortScore int64 `json:"sort_score,omitempty"`
CreatedAtMS int64 `json:"created_at_ms,omitempty"`
RoomID string `json:"room_id"`
}
// ListRooms 查询当前用户区域内的房间列表读模型。
func (s *Service) ListRooms(ctx context.Context, req *roomv1.ListRoomsRequest) (*roomv1.ListRoomsResponse, error) {
viewerUserID := req.GetViewerUserId()
if viewerUserID <= 0 {
// viewer_user_id 必须由 gateway 鉴权后填充room-service 不接受匿名列表查询。
return nil, xerr.New(xerr.InvalidArgument, "viewer_user_id is required")
}
tab := normalizeRoomListTab(req.GetTab())
if tab == "" {
return nil, xerr.New(xerr.InvalidArgument, "tab is invalid")
}
limit := normalizeRoomListLimit(req.GetLimit())
cursor, err := decodeRoomListCursor(tab, req.GetCursor())
if err != nil {
return nil, err
}
entries, err := s.repository.ListRoomListEntries(ctx, RoomListQuery{
VisibleRegionID: normalizeVisibleRegionID(req.GetVisibleRegionId()),
Tab: tab,
Limit: limit + 1,
CursorSortScore: cursor.SortScore,
CursorCreatedAtMS: cursor.CreatedAtMS,
CursorRoomID: cursor.RoomID,
})
if err != nil {
return nil, err
}
nextCursor := ""
if len(entries) > limit {
// 多查一条用于判断是否存在下一页;返回数据只包含请求的 limit 条。
last := entries[limit-1]
nextCursor = encodeRoomListCursor(tab, last)
entries = entries[:limit]
}
items := make([]*roomv1.RoomListItem, 0, len(entries))
for _, entry := range entries {
items = append(items, roomListItemToProto(entry))
}
return &roomv1.ListRoomsResponse{
Rooms: items,
NextCursor: nextCursor,
}, nil
}
// projectRoomListBestEffort 根据最新快照刷新房间列表投影。
func (s *Service) projectRoomListBestEffort(ctx context.Context, snapshot *roomv1.RoomSnapshot) {
if snapshot == nil || snapshot.GetRoomId() == "" {
return
}
meta, exists, err := s.repository.GetRoomMeta(ctx, snapshot.GetRoomId())
if err != nil {
// 投影不能回滚已提交 Room Cell 命令,先记录日志;后续可用 projection task 补偿。
log.Printf("component=room_list_projector room_id=%s status=load_meta_failed error=%q", snapshot.GetRoomId(), err.Error())
return
}
if !exists {
log.Printf("component=room_list_projector room_id=%s status=meta_missing", snapshot.GetRoomId())
return
}
nowMS := s.clock.Now().UnixMilli()
entry := roomListEntryFromSnapshot(snapshot, meta.VisibleRegionID, nowMS)
if err := s.repository.UpsertRoomListEntry(ctx, entry); err != nil {
// 列表读模型是最终一致;失败不改变命令成功语义。
log.Printf("component=room_list_projector room_id=%s region_id=%d status=upsert_failed error=%q", snapshot.GetRoomId(), meta.VisibleRegionID, err.Error())
}
}
// roomListEntryFromSnapshot 把完整房间快照压缩成列表卡片读模型。
// 这里刻意只提取列表需要的字段,避免列表表结构反向绑定 RoomState 全量细节。
func roomListEntryFromSnapshot(snapshot *roomv1.RoomSnapshot, visibleRegionID int64, nowMS int64) RoomListEntry {
onlineCount := int32(len(snapshot.GetOnlineUsers()))
seatCount := int32(len(snapshot.GetMicSeats()))
occupiedSeatCount := int32(0)
for _, seat := range snapshot.GetMicSeats() {
if seat.GetUserId() != 0 {
occupiedSeatCount++
}
}
title := snapshot.GetRoomExt()["title"]
coverURL := snapshot.GetRoomExt()["cover_url"]
return RoomListEntry{
RoomID: snapshot.GetRoomId(),
VisibleRegionID: normalizeVisibleRegionID(visibleRegionID),
OwnerUserID: snapshot.GetOwnerUserId(),
HostUserID: snapshot.GetHostUserId(),
Title: title,
CoverURL: coverURL,
Mode: snapshot.GetMode(),
Status: snapshot.GetStatus(),
Heat: snapshot.GetHeat(),
OnlineCount: onlineCount,
SeatCount: seatCount,
OccupiedSeatCount: occupiedSeatCount,
SortScore: roomListSortScore(snapshot.GetHeat(), onlineCount, occupiedSeatCount),
CreatedAtMS: nowMS,
UpdatedAtMS: nowMS,
}
}
func roomListSortScore(heat int64, onlineCount int32, occupiedSeatCount int32) int64 {
// 排序分只属于列表读模型,不进入 RoomState后续调整公式不需要回放 command log。
return heat*1000 + int64(onlineCount)*100 + int64(occupiedSeatCount)*10
}
// normalizeRoomListTab 固定首版只支持 hot/new未知 tab fail-closed。
func normalizeRoomListTab(tab string) string {
switch strings.ToLower(strings.TrimSpace(tab)) {
case "", roomListTabHot:
return roomListTabHot
case roomListTabNew:
return roomListTabNew
default:
return ""
}
}
// normalizeRoomListLimit 统一列表分页上限,防止客户端用大 limit 打穿 MySQL 读模型。
func normalizeRoomListLimit(limit int32) int {
if limit <= 0 {
return defaultRoomListLimit
}
if limit > maxRoomListLimit {
return maxRoomListLimit
}
return int(limit)
}
// normalizeVisibleRegionID 把负数区域收敛到 GLOBAL 桶,避免非法区域影响 SQL 查询边界。
func normalizeVisibleRegionID(regionID int64) int64 {
if regionID < 0 {
return 0
}
return regionID
}
// decodeRoomListCursor 校验不透明 cursor 和当前 tab 一致,禁止客户端跨排序维度复用游标。
func decodeRoomListCursor(tab string, encoded string) (roomListCursor, error) {
encoded = strings.TrimSpace(encoded)
if encoded == "" {
return roomListCursor{Tab: tab}, nil
}
payload, err := base64.RawURLEncoding.DecodeString(encoded)
if err != nil {
return roomListCursor{}, xerr.New(xerr.InvalidArgument, "cursor is invalid")
}
var cursor roomListCursor
if err := json.Unmarshal(payload, &cursor); err != nil {
return roomListCursor{}, xerr.New(xerr.InvalidArgument, "cursor is invalid")
}
if cursor.Tab != tab || cursor.RoomID == "" {
return roomListCursor{}, xerr.New(xerr.InvalidArgument, "cursor is invalid")
}
return cursor, nil
}
// encodeRoomListCursor 把最后一条列表记录编码成下一页边界。
// cursor 不暴露契约语义,后续调整排序字段时可以只兼容服务端解析逻辑。
func encodeRoomListCursor(tab string, entry RoomListEntry) string {
cursor := roomListCursor{
Tab: tab,
SortScore: entry.SortScore,
CreatedAtMS: entry.CreatedAtMS,
RoomID: entry.RoomID,
}
payload, err := json.Marshal(cursor)
if err != nil {
// 固定结构理论上不会 marshal 失败;失败时返回空游标让客户端停止翻页。
return ""
}
return base64.RawURLEncoding.EncodeToString(payload)
}
// roomListItemToProto 将 repository 投影转换为跨服务响应对象。
func roomListItemToProto(entry RoomListEntry) *roomv1.RoomListItem {
return &roomv1.RoomListItem{
RoomId: entry.RoomID,
OwnerUserId: entry.OwnerUserID,
HostUserId: entry.HostUserID,
Title: entry.Title,
CoverUrl: entry.CoverURL,
Mode: entry.Mode,
Status: entry.Status,
Heat: entry.Heat,
OnlineCount: entry.OnlineCount,
SeatCount: entry.SeatCount,
OccupiedSeatCount: entry.OccupiedSeatCount,
VisibleRegionId: entry.VisibleRegionID,
}
}