package service import ( "context" "encoding/base64" "encoding/json" "log" "strings" roomv1 "hyapp.local/api/proto/room/v1" "hyapp/pkg/appcode" "hyapp/pkg/xerr" ) const ( roomListTabHot = "hot" roomListTabNew = "new" roomListTabMe = "me" defaultRoomListLimit = 20 maxRoomListLimit = 50 maxRoomListQueryRunes = 64 ) // roomListCursor 是服务端生成的不透明翻页游标。 // 客户端只能原样传回,不能依赖内部字段;tab 写入游标用于拒绝跨 tab 复用。 type roomListCursor struct { Tab string `json:"tab"` Query string `json:"query,omitempty"` 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) { ctx = appcode.WithContext(ctx, req.GetMeta().GetAppCode()) 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()) query, err := normalizeRoomListQuery(req.GetQuery()) if err != nil { return nil, err } cursor, err := decodeRoomListCursor(tab, query, req.GetCursor()) if err != nil { return nil, err } ownerUserID := int64(0) if tab == roomListTabMe { ownerUserID = viewerUserID } entries, err := s.repository.ListRoomListEntries(ctx, RoomListQuery{ AppCode: appcode.FromContext(ctx), VisibleRegionID: normalizeVisibleRegionID(req.GetVisibleRegionId()), Tab: tab, OwnerUserID: ownerUserID, Query: query, 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, query, 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"] roomShortID := snapshot.GetRoomShortId() if roomShortID == "" { roomShortID = snapshot.GetRoomExt()["room_short_id"] } return RoomListEntry{ AppCode: snapshot.GetAppCode(), RoomID: snapshot.GetRoomId(), RoomShortID: roomShortID, 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/me,未知 tab fail-closed。 func normalizeRoomListTab(tab string) string { switch strings.ToLower(strings.TrimSpace(tab)) { case "", roomListTabHot: return roomListTabHot case roomListTabNew: return roomListTabNew case roomListTabMe: return roomListTabMe default: return "" } } func normalizeRoomListQuery(query string) (string, error) { query = strings.TrimSpace(query) if query == "" { return "", nil } if len([]rune(query)) > maxRoomListQueryRunes { return "", xerr.New(xerr.InvalidArgument, "query is too long") } return query, nil } // 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, query string, encoded string) (roomListCursor, error) { encoded = strings.TrimSpace(encoded) if encoded == "" { return roomListCursor{Tab: tab, Query: query}, 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.Query != query || cursor.RoomID == "" { return roomListCursor{}, xerr.New(xerr.InvalidArgument, "cursor is invalid") } return cursor, nil } // encodeRoomListCursor 把最后一条列表记录编码成下一页边界。 // cursor 不暴露契约语义,后续调整排序字段时可以只兼容服务端解析逻辑。 func encodeRoomListCursor(tab string, query string, entry RoomListEntry) string { cursor := roomListCursor{ Tab: tab, Query: query, 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{ AppCode: entry.AppCode, RoomId: entry.RoomID, RoomShortId: entry.RoomShortID, 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, } }