package service import ( "context" "encoding/base64" "encoding/json" "log/slog" "math" "strings" roomv1 "hyapp.local/api/proto/room/v1" "hyapp/pkg/appcode" "hyapp/pkg/logx" "hyapp/pkg/xerr" ) const ( roomListTabHot = "hot" roomListTabNew = "new" roomListTabVisited = "visited" roomListTabFriend = "friend" roomListTabFollowing = "following" roomListTabFollowed = "followed" defaultRoomListLimit = 20 maxRoomListLimit = 50 maxRoomListQueryRunes = 64 roomHotHeatWeight = 650000 roomHotOnlineWeight = 300000 roomHotMicWeight = 50000 roomHotOnlineCap = 50 roomHotMicCap = 10 ) // roomListCursor 是服务端生成的不透明翻页游标。 // 客户端只能原样传回,不能依赖内部字段;tab 写入游标用于拒绝跨 tab 复用。 type roomListCursor struct { Tab string `json:"tab"` Query string `json:"query,omitempty"` CountryCode string `json:"country_code,omitempty"` ViewerCountry string `json:"viewer_country,omitempty"` AllRegions bool `json:"all_regions,omitempty"` SortScore int64 `json:"sort_score,omitempty"` CreatedAtMS int64 `json:"created_at_ms,omitempty"` UpdatedAtMS int64 `json:"updated_at_ms,omitempty"` SubjectUserID int64 `json:"subject_user_id,omitempty"` PinListRank int64 `json:"pin_list_rank,omitempty"` Pinned bool `json:"pinned,omitempty"` PinWeight int64 `json:"pin_weight,omitempty"` PinnedUntilMS int64 `json:"pinned_until_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 } countryCode := normalizeRoomCountryCode(req.GetCountryCode()) viewerCountryCode := normalizeRoomCountryCode(req.GetViewerCountryCode()) allVisibleRegions := req.GetAllVisibleRegions() cursor, err := decodeRoomListCursor(tab, query, countryCode, viewerCountryCode, allVisibleRegions, req.GetCursor()) if err != nil { return nil, err } entries, err := s.listRoomListEntries(ctx, RoomListQuery{ AppCode: appcode.FromContext(ctx), VisibleRegionID: normalizeVisibleRegionID(req.GetVisibleRegionId()), AllVisibleRegions: allVisibleRegions, Tab: tab, Query: query, CountryCode: countryCode, ViewerCountryCode: viewerCountryCode, Limit: limit + 1, CursorSortScore: cursor.SortScore, CursorCreatedAtMS: cursor.CreatedAtMS, CursorRoomID: cursor.RoomID, CursorPinListRank: cursor.PinListRank, CursorPinned: cursor.Pinned, CursorPinWeight: cursor.PinWeight, CursorPinnedUntilMS: cursor.PinnedUntilMS, NowMS: s.clock.Now().UnixMilli(), }) if err != nil { return nil, err } return roomListResponseFromEntries(tab, query, countryCode, viewerCountryCode, allVisibleRegions, entries, limit), nil } func (s *Service) listRoomListEntries(ctx context.Context, query RoomListQuery) ([]RoomListEntry, error) { if s != nil && s.roomListCache != nil && roomListCacheEligible(query) { entries, ok, err := s.roomListCache.ListRoomListEntries(ctx, query) if err == nil && ok { return entries, nil } if err != nil { // 缓存只是展示读模型,Redis 抖动或扫描保护不能影响发现页可用性;记录原因后走 MySQL 权威投影。 logx.Warn(ctx, "room_list_cache_fallback", slog.String("tab", query.Tab), slog.String("error", err.Error())) } } return s.repository.ListRoomListEntries(ctx, query) } func roomListCacheEligible(query RoomListQuery) bool { // 搜索、全区白名单和内部定向查询仍走 MySQL,避免为了低频条件维护过宽的 Redis 索引。 if strings.TrimSpace(query.Query) != "" || query.AllVisibleRegions || query.OwnerUserID > 0 { return false } return query.Tab == roomListTabHot || query.Tab == roomListTabNew } // ListRoomFeeds 查询 Mine 页 visited/friend/following/followed 房间流。 func (s *Service) ListRoomFeeds(ctx context.Context, req *roomv1.ListRoomFeedsRequest) (*roomv1.ListRoomsResponse, error) { ctx = appcode.WithContext(ctx, req.GetMeta().GetAppCode()) viewerUserID := req.GetViewerUserId() if viewerUserID <= 0 { // feed 一定绑定当前登录用户,不能允许匿名或客户端自选用户。 return nil, xerr.New(xerr.InvalidArgument, "viewer_user_id is required") } tab := normalizeRoomFeedTab(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, "", "", false, req.GetCursor()) if err != nil { return nil, err } if roomFeedTabRequiresRelationCursor(tab) && strings.TrimSpace(req.GetCursor()) != "" && cursor.SubjectUserID <= 0 { return nil, xerr.New(xerr.InvalidArgument, "cursor is invalid") } var entries []RoomListEntry switch tab { case roomListTabVisited: entries, err = s.repository.ListRoomUserFeedEntries(ctx, RoomUserFeedQuery{ AppCode: appcode.FromContext(ctx), UserID: viewerUserID, FeedType: tab, VisibleRegionID: normalizeVisibleRegionID(req.GetVisibleRegionId()), Query: query, Limit: limit + 1, CursorUpdatedAtMS: cursor.UpdatedAtMS, CursorRoomID: cursor.RoomID, }) case roomListTabFollowed: entries, err = s.repository.ListRoomFollowEntries(ctx, RoomFollowQuery{ AppCode: appcode.FromContext(ctx), UserID: viewerUserID, VisibleRegionID: normalizeVisibleRegionID(req.GetVisibleRegionId()), Query: query, Limit: limit + 1, CursorFollowedAtMS: cursor.UpdatedAtMS, CursorRoomID: cursor.RoomID, }) default: // friend/following 的关系事实属于 user-service;room-service 只根据 gateway 传入的一页关系用户查询 active 房间卡片。 entries, err = s.repository.ListRoomRelatedFeedEntries(ctx, RoomRelatedFeedQuery{ AppCode: appcode.FromContext(ctx), VisibleRegionID: normalizeVisibleRegionID(req.GetVisibleRegionId()), RelatedUsers: roomFeedRelatedUsersFromProto(req.GetRelatedUsers()), Query: query, Limit: limit + 1, CursorUpdatedAtMS: cursor.UpdatedAtMS, CursorSubjectUserID: cursor.SubjectUserID, CursorRoomID: cursor.RoomID, }) } if err != nil { return nil, err } return roomListResponseFromEntries(tab, query, "", "", false, entries, limit), nil } // GetMyRoom 查询当前用户自己创建的房间,不依赖 room_list_entries 投影命中。 func (s *Service) GetMyRoom(ctx context.Context, req *roomv1.GetMyRoomRequest) (*roomv1.GetMyRoomResponse, error) { ctx = appcode.WithContext(ctx, req.GetMeta().GetAppCode()) ownerUserID := req.GetOwnerUserId() if ownerUserID <= 0 { // owner_user_id 必须由 gateway 鉴权后填充,Mine 卡片不能接受客户端任意查人。 return nil, xerr.New(xerr.InvalidArgument, "owner_user_id is required") } serverTimeMS := s.clock.Now().UnixMilli() meta, exists, err := s.repository.GetRoomMetaByOwner(ctx, ownerUserID) if err != nil { return nil, err } if !exists { return &roomv1.GetMyRoomResponse{ServerTimeMs: serverTimeMS}, nil } snapshot, err := s.currentSnapshot(ctx, meta.RoomID) if err != nil { return nil, err } return &roomv1.GetMyRoomResponse{ HasRoom: true, Room: roomListItemToProto(roomListEntryFromMetaAndSnapshot(meta, snapshot, serverTimeMS)), ServerTimeMs: serverTimeMS, }, nil } func roomListResponseFromEntries(tab string, query string, countryCode string, viewerCountryCode string, allRegions bool, entries []RoomListEntry, limit int) *roomv1.ListRoomsResponse { nextCursor := "" if len(entries) > limit { // 多查一条用于判断是否存在下一页;返回数据只包含请求的 limit 条。 last := entries[limit-1] nextCursor = encodeRoomListCursor(tab, query, countryCode, viewerCountryCode, allRegions, last) entries = entries[:limit] } items := make([]*roomv1.RoomListItem, 0, len(entries)) for _, entry := range entries { // response 只暴露列表卡片字段,不把 repository 的 cursor 辅助字段泄露给客户端。 items = append(items, roomListItemToProto(entry)) } return &roomv1.ListRoomsResponse{ Rooms: items, NextCursor: nextCursor, } } func roomFeedRelatedUsersFromProto(items []*roomv1.RoomFeedRelatedUser) []RoomFeedRelatedUser { // gateway 已做关系授权,room-service 这里只过滤无效项并保持输入顺序供 repository 排序使用。 relatedUsers := make([]RoomFeedRelatedUser, 0, len(items)) for _, item := range items { if item == nil || item.GetUserId() <= 0 || item.GetRelationUpdatedAtMs() <= 0 { continue } relatedUsers = append(relatedUsers, RoomFeedRelatedUser{ UserID: item.GetUserId(), RelationUpdatedAtMS: item.GetRelationUpdatedAtMs(), }) } return relatedUsers } // 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 补偿。 logx.Error(ctx, "room_list_load_meta_failed", err, slog.String("component", "room_list_projector"), slog.String("room_id", snapshot.GetRoomId())) return } if !exists { logx.Warn(ctx, "room_list_meta_missing", slog.String("component", "room_list_projector"), slog.String("room_id", snapshot.GetRoomId())) return } nowMS := s.clock.Now().UnixMilli() entry := roomListEntryFromSnapshot(snapshot, meta.VisibleRegionID, nowMS) if err := s.repository.UpsertRoomListEntry(ctx, entry); err != nil { // 列表读模型是最终一致;失败不改变命令成功语义。 logx.Error(ctx, "room_list_upsert_failed", err, slog.String("component", "room_list_projector"), slog.String("room_id", snapshot.GetRoomId()), slog.Int64("region_id", meta.VisibleRegionID)) return } s.projectRoomListCacheBestEffort(ctx, entry) } func (s *Service) projectRoomListCacheBestEffort(ctx context.Context, entry RoomListEntry) { if s == nil || s.roomListCache == nil { return } var err error if strings.TrimSpace(entry.Status) == "active" { err = s.roomListCache.UpsertRoomListEntry(ctx, entry) } else { err = s.roomListCache.DeleteRoomListEntry(ctx, entry.AppCode, entry.RoomID) } if err != nil { // Redis 列表缓存可由 MySQL 扫描重建,写失败不能回滚已经提交的房间命令。 logx.Warn(ctx, "room_list_cache_project_failed", slog.String("room_id", entry.RoomID), slog.String("status", entry.Status), slog.String("error", 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++ } } // 展示资料仍放在 RoomExt,列表投影只在刷新时复制需要的字段。 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), OwnerCountryCode: normalizeRoomCountryCode(snapshot.GetRoomExt()[roomExtOwnerCountryKey]), OwnerUserID: snapshot.GetOwnerUserId(), Title: title, CoverURL: coverURL, Mode: snapshot.GetMode(), Status: snapshot.GetStatus(), Locked: snapshot.GetLocked(), 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;贡献值先做 log 压缩,避免历史大房间永久碾压当前有人房间。 if heat < 0 { heat = 0 } online := int64(onlineCount) if online < 0 { online = 0 } if online > roomHotOnlineCap { online = roomHotOnlineCap } occupied := int64(occupiedSeatCount) if occupied < 0 { occupied = 0 } if occupied > roomHotMicCap { occupied = roomHotMicCap } heatScore := math.Log2(float64(heat) + 1) onlineScore := math.Log2(float64(online) + 1) micScore := float64(occupied) return int64(heatScore*roomHotHeatWeight + onlineScore*roomHotOnlineWeight + micScore*roomHotMicWeight) } // normalizeRoomListTab 只支持公共发现列表 tab,Mine 关系流走 ListRoomFeeds。 func normalizeRoomListTab(tab string) string { switch strings.ToLower(strings.TrimSpace(tab)) { case "", roomListTabHot: return roomListTabHot case roomListTabNew: return roomListTabNew default: return "" } } func normalizeRoomFeedTab(tab string) string { // Mine 页房间流只允许明确 tab,避免空 tab 被误解成公共 hot 列表。 switch strings.ToLower(strings.TrimSpace(tab)) { case roomListTabVisited: return roomListTabVisited case roomListTabFriend: return roomListTabFriend case roomListTabFollowing: return roomListTabFollowing case roomListTabFollowed: return roomListTabFollowed default: return "" } } func roomFeedTabRequiresRelationCursor(tab string) bool { // friend/following 的排序边界包含关系用户 ID,visited/followed 只需要房间 ID 和时间。 return tab == roomListTabFriend || tab == roomListTabFollowing } func normalizeRoomListQuery(query string) (string, error) { query = strings.TrimSpace(query) if query == "" { return "", nil } if len([]rune(query)) > maxRoomListQueryRunes { // 搜索词进入 LIKE 查询,长度必须在入口收敛,避免大字符串放大数据库扫描成本。 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 } // normalizeRoomCountryCode 把国家码收敛为大写字母;非法值按空值处理,避免污染 SQL 精确筛选。 func normalizeRoomCountryCode(countryCode string) string { countryCode = strings.ToUpper(strings.TrimSpace(countryCode)) if len(countryCode) < 2 || len(countryCode) > 3 { return "" } for _, ch := range countryCode { if ch < 'A' || ch > 'Z' { return "" } } return countryCode } // decodeRoomListCursor 校验不透明 cursor 和当前 tab 一致,禁止客户端跨排序维度复用游标。 func decodeRoomListCursor(tab string, query string, countryCode string, viewerCountryCode string, allRegions bool, encoded string) (roomListCursor, error) { encoded = strings.TrimSpace(encoded) if encoded == "" { return roomListCursor{Tab: tab, Query: query, CountryCode: countryCode, ViewerCountry: viewerCountryCode, AllRegions: allRegions}, 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.CountryCode != countryCode || cursor.ViewerCountry != viewerCountryCode || cursor.AllRegions != allRegions || cursor.RoomID == "" { return roomListCursor{}, xerr.New(xerr.InvalidArgument, "cursor is invalid") } return cursor, nil } // encodeRoomListCursor 把最后一条列表记录编码成下一页边界。 // cursor 不暴露契约语义,后续调整排序字段时可以只兼容服务端解析逻辑。 func encodeRoomListCursor(tab string, query string, countryCode string, viewerCountryCode string, allRegions bool, entry RoomListEntry) string { cursor := roomListCursor{ Tab: tab, Query: query, CountryCode: countryCode, ViewerCountry: viewerCountryCode, AllRegions: allRegions, SortScore: entry.SortScore, CreatedAtMS: entry.CreatedAtMS, UpdatedAtMS: entry.UpdatedAtMS, SubjectUserID: entry.FeedSubjectUserID, PinListRank: entry.PinListRank, Pinned: entry.IsPinned, PinWeight: entry.PinWeight, PinnedUntilMS: entry.PinnedUntilMS, 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, Title: entry.Title, CoverUrl: entry.CoverURL, Mode: entry.Mode, Status: entry.Status, Locked: entry.Locked, Heat: entry.Heat, OnlineCount: entry.OnlineCount, SeatCount: entry.SeatCount, OccupiedSeatCount: entry.OccupiedSeatCount, VisibleRegionId: entry.VisibleRegionID, CountryCode: normalizeRoomCountryCode(entry.OwnerCountryCode), } } func roomListEntryFromMetaAndSnapshot(meta RoomMeta, snapshot *roomv1.RoomSnapshot, nowMS int64) RoomListEntry { if snapshot == nil || snapshot.GetRoomId() == "" { // GetMyRoom 兜底路径:即使快照暂时不可用,也能用 rooms 元数据返回“有房间”的最小卡片。 return RoomListEntry{ AppCode: meta.AppCode, RoomID: meta.RoomID, RoomShortID: meta.RoomShortID, VisibleRegionID: normalizeVisibleRegionID(meta.VisibleRegionID), OwnerCountryCode: "", OwnerUserID: meta.OwnerUserID, Mode: meta.Mode, Status: meta.Status, Locked: meta.RoomPasswordHash != "", SeatCount: meta.SeatCount, CreatedAtMS: nowMS, UpdatedAtMS: nowMS, } } entry := roomListEntryFromSnapshot(snapshot, meta.VisibleRegionID, nowMS) if entry.RoomShortID == "" { // 老快照可能没有 room_short_id,rooms meta 是短号的持久兜底来源。 entry.RoomShortID = meta.RoomShortID } if entry.Mode == "" { // mode/status/owner 都从 meta 兜底,避免快照扩展字段缺失导致 Mine 卡片不可用。 entry.Mode = meta.Mode } if entry.Status == "" { entry.Status = meta.Status } if entry.OwnerUserID <= 0 { entry.OwnerUserID = meta.OwnerUserID } return entry }