package roomapi import ( "context" "encoding/base64" "encoding/json" "fmt" "hyapp/services/gateway-service/internal/transport/http/httpkit" "net/http" "strconv" "strings" "sync" "time" "hyapp/pkg/appcode" "hyapp/pkg/roomid" "hyapp/pkg/xerr" "hyapp/services/gateway-service/internal/auth" "google.golang.org/grpc/codes" "google.golang.org/grpc/status" activityv1 "hyapp.local/api/proto/activity/v1" roomv1 "hyapp.local/api/proto/room/v1" userv1 "hyapp.local/api/proto/user/v1" walletv1 "hyapp.local/api/proto/wallet/v1" ) const ( defaultRoomFeedRelationPageSize = 100 maxRoomFeedRelationScanCount = 500 // 房间展示资料只读取客户端能在首屏、公屏或资料卡直接渲染的当前佩戴资源。 roomAppearanceResourceAvatarFrame = "avatar_frame" roomAppearanceResourceProfileCard = "profile_card" roomAppearanceResourceVehicle = "vehicle" roomAppearanceResourceBadge = "badge" roomAppearanceResourceMicSeatAnimation = "mic_seat_animation" // 机器人房标记来自 room-service RoomExt;gateway 只用它决定首屏资料是否补机器人头像昵称。 roomExtRobotRoomKey = "robot_room" roomExtRobotUserIDsKey = "robot_user_ids" // JoinRoom 默认保持历史完整包;lite 只保留进房首屏必要字段,把装扮/榜单等增强展示后移。 joinRoomResponseModeFull = "full" joinRoomResponseModeLite = "lite" ) type flexibleInt64 int64 func (v *flexibleInt64) UnmarshalJSON(data []byte) error { raw := strings.TrimSpace(string(data)) if raw == "" || raw == "null" { *v = 0 return nil } if strings.HasPrefix(raw, `"`) && strings.HasSuffix(raw, `"`) { var text string if err := json.Unmarshal(data, &text); err != nil { return err } raw = strings.TrimSpace(text) } if raw == "" { *v = 0 return nil } parsed, err := strconv.ParseInt(raw, 10, 64) if err != nil { return err } *v = flexibleInt64(parsed) return nil } type roomFeedRelationCursor struct { Tab string `json:"tab"` Query string `json:"query,omitempty"` UpdatedAtMS int64 `json:"updated_at_ms,omitempty"` SubjectUserID int64 `json:"subject_user_id,omitempty"` RoomID string `json:"room_id"` } // listRooms 按当前登录用户的服务端 region_id 查询房间发现列表。 func (h *Handler) listRooms(writer http.ResponseWriter, request *http.Request) { limit, ok := parseRoomListLimit(request.URL.Query().Get("limit")) if !ok { httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument") return } tab, ok := parsePublicRoomListTab(request.URL.Query().Get("tab")) if !ok { httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument") return } if h.userProfileClient == nil || h.roomQueryClient == nil || h.userRegionClient == nil || h.userCountryClient == nil { // 列表入口必须同时依赖 user-service 用户区域、区域国家、国家主数据和 room-service 读模型。 httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error") return } viewerUserID := auth.UserIDFromContext(request.Context()) userResp, err := h.userProfileClient.GetUser(request.Context(), &userv1.GetUserRequest{ Meta: httpkit.UserMeta(request, ""), UserId: viewerUserID, }) if err != nil { // user-service 是 region_id 的唯一 owner,查询失败不能用 IP 国家兜底。 httpkit.WriteRPCError(writer, request, err) return } countries, err := h.roomListCountriesForUser(request, userResp.GetUser()) if err != nil { httpkit.WriteRPCError(writer, request, err) return } countryCode, ok := roomListCountryQuery(request, countries) if !ok { httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument") return } allVisibleRegions := h.roomRegionWhitelistAllows(request, viewerUserID) resp, err := h.roomQueryClient.ListRooms(request.Context(), &roomv1.ListRoomsRequest{ Meta: httpkit.RoomMeta(request, "", ""), ViewerUserId: viewerUserID, VisibleRegionId: userResp.GetUser().GetRegionId(), ViewerCountryCode: userResp.GetUser().GetCountry(), AllVisibleRegions: allVisibleRegions, Tab: tab, Cursor: request.URL.Query().Get("cursor"), Limit: limit, Query: roomListQuery(request), CountryCode: countryCode, }) if err != nil { httpkit.WriteRPCError(writer, request, err) return } data := roomListDataFromProto(resp) data.Countries = countries httpkit.WriteOK(writer, request, data) } // listRoomFeeds 查询 Mine 页 visited/friend/following/followed 房间流。 func (h *Handler) listRoomFeeds(writer http.ResponseWriter, request *http.Request) { if h.userProfileClient == nil || h.roomQueryClient == nil { // feed 同样需要 user-service 区域归属,避免跨区域露出用户关系流房间。 httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error") return } limit, ok := parseRoomListLimit(request.URL.Query().Get("limit")) if !ok { httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument") return } tab, ok := parseRoomFeedTab(request.URL.Query().Get("tab")) if !ok { httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument") return } viewerUserID := auth.UserIDFromContext(request.Context()) userResp, err := h.userProfileClient.GetUser(request.Context(), &userv1.GetUserRequest{ Meta: httpkit.UserMeta(request, ""), UserId: viewerUserID, }) if err != nil { httpkit.WriteRPCError(writer, request, err) return } relatedUsers, ok, err := h.roomFeedRelatedUsers(request, tab, roomListQuery(request), request.URL.Query().Get("cursor")) if !ok { httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument") return } if err != nil { httpkit.WriteRPCError(writer, request, err) return } resp, err := h.roomQueryClient.ListRoomFeeds(request.Context(), &roomv1.ListRoomFeedsRequest{ Meta: httpkit.RoomMeta(request, "", ""), ViewerUserId: viewerUserID, VisibleRegionId: userResp.GetUser().GetRegionId(), Tab: tab, Cursor: request.URL.Query().Get("cursor"), Limit: limit, Query: roomListQuery(request), RelatedUsers: relatedUsers, }) if err != nil { httpkit.WriteRPCError(writer, request, err) return } httpkit.WriteOK(writer, request, roomListDataFromProto(resp)) } func (h *Handler) roomRegionWhitelistAllows(request *http.Request, viewerUserID int64) bool { if h.appConfigReader == nil || viewerUserID <= 0 { return false } allowed, err := h.appConfigReader.RoomRegionWhitelistAllows(request.Context(), appcode.FromContext(request.Context()), viewerUserID) if err != nil { // 白名单读取失败时必须 fail-closed,避免后台配置或缓存异常放大成跨区域房间泄露。 return false } return allowed } func (h *Handler) roomFeedRelatedUsers(request *http.Request, tab string, query string, rawCursor string) ([]*roomv1.RoomFeedRelatedUser, bool, error) { if tab == "visited" || tab == "followed" { return nil, true, nil } if h.userSocialClient == nil { // friend/following 必须以 user-service 关系事实为准,不能退回旧 room feed 投影。 return nil, true, xerr.New(xerr.Unavailable, "user social client is not configured") } cursor, ok := decodeRoomFeedRelationCursor(tab, query, rawCursor) if !ok { return nil, false, nil } viewerUserID := auth.UserIDFromContext(request.Context()) relatedUsers := make([]*roomv1.RoomFeedRelatedUser, 0, defaultRoomFeedRelationPageSize) for len(relatedUsers) < maxRoomFeedRelationScanCount { batch, nextCursor, err := h.roomFeedRelatedUserBatch(request, tab, viewerUserID, cursor) if err != nil { return nil, true, err } relatedUsers = append(relatedUsers, batch...) if len(batch) < defaultRoomFeedRelationPageSize { break } cursor = nextCursor } return relatedUsers, true, nil } func (h *Handler) roomFeedRelatedUserBatch(request *http.Request, tab string, viewerUserID int64, cursor roomFeedRelationCursor) ([]*roomv1.RoomFeedRelatedUser, roomFeedRelationCursor, error) { switch tab { case "following": resp, err := h.userSocialClient.ListFollowing(request.Context(), &userv1.ListFollowingRequest{ Meta: httpkit.UserMeta(request, ""), UserId: viewerUserID, Page: 1, PageSize: defaultRoomFeedRelationPageSize, CursorUpdatedAtMs: cursor.UpdatedAtMS, CursorUserId: cursor.SubjectUserID, }) if err != nil { return nil, cursor, err } relatedUsers := make([]*roomv1.RoomFeedRelatedUser, 0, len(resp.GetRecords())) for _, record := range resp.GetRecords() { relatedUsers = append(relatedUsers, &roomv1.RoomFeedRelatedUser{ UserId: record.GetFolloweeUserId(), RelationUpdatedAtMs: record.GetFollowedAtMs(), }) cursor.UpdatedAtMS = record.GetFollowedAtMs() cursor.SubjectUserID = record.GetFolloweeUserId() } return relatedUsers, cursor, nil case "friend": resp, err := h.userSocialClient.ListFriends(request.Context(), &userv1.ListFriendsRequest{ Meta: httpkit.UserMeta(request, ""), UserId: viewerUserID, Page: 1, PageSize: defaultRoomFeedRelationPageSize, CursorUpdatedAtMs: cursor.UpdatedAtMS, CursorUserId: cursor.SubjectUserID, }) if err != nil { return nil, cursor, err } relatedUsers := make([]*roomv1.RoomFeedRelatedUser, 0, len(resp.GetRecords())) for _, record := range resp.GetRecords() { relatedUsers = append(relatedUsers, &roomv1.RoomFeedRelatedUser{ UserId: record.GetFriendUserId(), RelationUpdatedAtMs: record.GetFriendedAtMs(), }) cursor.UpdatedAtMS = record.GetFriendedAtMs() cursor.SubjectUserID = record.GetFriendUserId() } return relatedUsers, cursor, nil default: return nil, cursor, xerr.New(xerr.InvalidArgument, "tab is invalid") } } func decodeRoomFeedRelationCursor(tab string, query string, raw string) (roomFeedRelationCursor, bool) { raw = strings.TrimSpace(raw) if raw == "" { return roomFeedRelationCursor{Tab: tab, Query: query}, true } payload, err := base64.RawURLEncoding.DecodeString(raw) if err != nil { return roomFeedRelationCursor{}, false } var cursor roomFeedRelationCursor if err := json.Unmarshal(payload, &cursor); err != nil { return roomFeedRelationCursor{}, false } if cursor.Tab != tab || cursor.Query != query || cursor.RoomID == "" || cursor.UpdatedAtMS <= 0 || cursor.SubjectUserID <= 0 { return roomFeedRelationCursor{}, false } return cursor, true } func parsePublicRoomListTab(raw string) (string, bool) { switch strings.ToLower(strings.TrimSpace(raw)) { case "", "hot": return "hot", true case "new": return "new", true default: return "", false } } func parseRoomFeedTab(raw string) (string, bool) { switch strings.ToLower(strings.TrimSpace(raw)) { case "visited": return "visited", true case "friend": return "friend", true case "following": return "following", true case "followed": return "followed", true default: return "", false } } func roomListQuery(request *http.Request) string { if query := strings.TrimSpace(request.URL.Query().Get("q")); query != "" { return query } return strings.TrimSpace(request.URL.Query().Get("query")) } func (h *Handler) roomListCountriesForUser(request *http.Request, user *userv1.User) ([]roomListCountryData, error) { if user == nil || user.GetRegionId() <= 0 { // 当前用户区域来自 user-service 主数据;缺失时不能猜测区域国家列表。 return nil, xerr.New(xerr.InvalidArgument, "user region is required") } regionResp, err := h.userRegionClient.GetRegion(request.Context(), &userv1.GetRegionRequest{ Meta: httpkit.UserMeta(request, ""), RegionId: user.GetRegionId(), }) if err != nil { return nil, err } countryResp, err := h.userCountryClient.ListRegistrationCountries(request.Context(), &userv1.ListRegistrationCountriesRequest{ Meta: httpkit.UserMeta(request, ""), }) if err != nil { return nil, err } return roomListCountriesFromProto(user, regionResp.GetRegion(), countryResp.GetCountries()), nil } func roomListCountriesFromProto(user *userv1.User, region *userv1.Region, countries []*userv1.Country) []roomListCountryData { countriesByCode := make(map[string]*userv1.Country, len(countries)) for _, country := range countries { code := normalizeRoomListCountryCode(country.GetCountryCode()) if code == "" { continue } countriesByCode[code] = country } seen := make(map[string]struct{}) regionCodes := make([]string, 0, len(region.GetCountries())) regionCodeSet := make(map[string]struct{}, len(region.GetCountries())) if region != nil { for _, rawCode := range region.GetCountries() { code := normalizeRoomListCountryCode(rawCode) if code == "" { continue } regionCodes = append(regionCodes, code) regionCodeSet[code] = struct{}{} } } result := make([]roomListCountryData, 0, len(countriesByCode)) add := func(code string) { code = normalizeRoomListCountryCode(code) if code == "" { return } if _, exists := seen[code]; exists { return } seen[code] = struct{}{} result = append(result, roomListCountryDataFromProto(code, countriesByCode[code])) } // 国家列表严格来自当前区域;用户国家命中区域配置时提前到首位,其余国家保持 user-service 区域顺序。 if user != nil { if _, exists := regionCodeSet[normalizeRoomListCountryCode(user.GetCountry())]; exists { add(user.GetCountry()) } } for _, code := range regionCodes { add(code) } return result } func roomListCountryDataFromProto(code string, country *userv1.Country) roomListCountryData { item := roomListCountryData{ CountryCode: code, CountryFlag: roomProfileCountryFlag(code), } if country == nil { return item } item.CountryID = country.GetCountryId() item.CountryName = strings.TrimSpace(country.GetCountryName()) item.CountryDisplayName = strings.TrimSpace(country.GetCountryDisplayName()) if flag := strings.TrimSpace(country.GetFlag()); flag != "" { item.CountryFlag = flag } item.SortOrder = country.GetSortOrder() return item } func roomListCountryQuery(request *http.Request, countries []roomListCountryData) (string, bool) { raw := strings.TrimSpace(request.URL.Query().Get("country")) if raw == "" { raw = strings.TrimSpace(request.URL.Query().Get("country_code")) } code := normalizeRoomListCountryCode(raw) if code == "" { return "", raw == "" } for _, country := range countries { if normalizeRoomListCountryCode(country.CountryCode) == code { return code, true } } return "", false } func normalizeRoomListCountryCode(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 } // parseRoomListLimit 只做 HTTP 字符串到数字的传输层解析。 // 默认值和最大值仍由 room-service 统一裁剪,避免 gateway 和查询服务出现双份策略。 func parseRoomListLimit(raw string) (int32, bool) { raw = strings.TrimSpace(raw) if raw == "" { return 0, true } value, err := strconv.Atoi(raw) if err != nil { return 0, false } return int32(value), true } // getMyRoom 返回 Mine 顶部“我的房间”卡片。 // 这个入口直接查 room-service 的 owner 权威数据,不依赖发现页 room_list_entries 投影是否命中。 func (h *Handler) getMyRoom(writer http.ResponseWriter, request *http.Request) { if h.roomQueryClient == nil { httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error") return } resp, err := h.roomQueryClient.GetMyRoom(request.Context(), &roomv1.GetMyRoomRequest{ Meta: httpkit.RoomMeta(request, "", ""), OwnerUserId: auth.UserIDFromContext(request.Context()), }) if err != nil { httpkit.WriteRPCError(writer, request, err) return } httpkit.WriteOK(writer, request, myRoomDataFromProto(resp)) } // getCurrentRoom 查询当前登录用户是否仍有可恢复房间。 // gateway 只传鉴权 user_id;presence 和麦位状态由 room-service 用读模型和 Room Cell 快照判断。 func (h *Handler) getCurrentRoom(writer http.ResponseWriter, request *http.Request) { if h.roomQueryClient == nil { httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error") return } resp, err := h.roomQueryClient.GetCurrentRoom(request.Context(), &roomv1.GetCurrentRoomRequest{ Meta: httpkit.RoomMeta(request, "", ""), UserId: auth.UserIDFromContext(request.Context()), }) if err != nil { httpkit.WriteRPCError(writer, request, err) return } httpkit.WriteOK(writer, request, currentRoomDataFromProto(resp)) } // currentRoomData 是 gateway 的外部 JSON 契约,显式保留 false/0 字段。 // 不能直接把 protobuf response 写出去,否则 proto3 的 omitempty 会让 has_current_room=false 消失。 type currentRoomData struct { HasCurrentRoom bool `json:"has_current_room"` RoomID string `json:"room_id"` RoomVersion int64 `json:"room_version"` Role string `json:"role"` MicSessionID string `json:"mic_session_id"` PublishState string `json:"publish_state"` NeedJoinIMGroup bool `json:"need_join_im_group"` NeedRTCToken bool `json:"need_rtc_token"` ServerTimeMS int64 `json:"server_time_ms"` } func currentRoomDataFromProto(resp *roomv1.GetCurrentRoomResponse) currentRoomData { if resp == nil { return currentRoomData{} } return currentRoomData{ HasCurrentRoom: resp.GetHasCurrentRoom(), RoomID: resp.GetRoomId(), RoomVersion: resp.GetRoomVersion(), Role: resp.GetRole(), MicSessionID: resp.GetMicSessionId(), PublishState: resp.GetPublishState(), NeedJoinIMGroup: resp.GetNeedJoinImGroup(), NeedRTCToken: resp.GetNeedRtcToken(), ServerTimeMS: resp.GetServerTimeMs(), } } // getRoomSnapshot 主动拉取房间页完整快照。 // 该接口只读 Room Cell/snapshot,不刷新 heartbeat,也不隐式 JoinRoom。 func (h *Handler) getRoomSnapshot(writer http.ResponseWriter, request *http.Request) { if h.roomQueryClient == nil { httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error") return } roomID := strings.TrimSpace(request.URL.Query().Get("room_id")) if !roomid.ValidStringID(roomID) { httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument") return } resp, err := h.roomQueryClient.GetRoomSnapshot(request.Context(), &roomv1.GetRoomSnapshotRequest{ Meta: httpkit.RoomMeta(request, roomID, ""), RoomId: roomID, ViewerUserId: auth.UserIDFromContext(request.Context()), }) httpkit.Write(writer, request, roomSnapshotDataFromProto(resp), err) } // getRoomDetail 返回语音房详情页首屏可直接渲染的数据。 // 它是 GetRoomSnapshot 的 App 聚合版:补 viewer 权限、IM/RTC 入房参数和首屏展示资料。 func (h *Handler) getRoomDetail(writer http.ResponseWriter, request *http.Request) { if h.roomQueryClient == nil { httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error") return } roomID := strings.TrimSpace(request.PathValue("room_id")) if !roomid.ValidStringID(roomID) { httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument") return } viewerUserID := auth.UserIDFromContext(request.Context()) resp, err := h.roomQueryClient.GetRoomSnapshot(request.Context(), &roomv1.GetRoomSnapshotRequest{ Meta: httpkit.RoomMeta(request, roomID, ""), RoomId: roomID, ViewerUserId: viewerUserID, }) if err != nil { httpkit.WriteRPCError(writer, request, err) return } profiles := h.roomDisplayProfiles(request, roomInitialProfileUserIDs(resp.GetRoom(), viewerUserID, joinRoomContributionRankLimit)) httpkit.WriteOK(writer, request, roomDetailDataFromSnapshot(resp, viewerUserID, h.joinRoomRTCData(roomID, viewerUserID), profiles)) } // listRoomOnlineUsers 分页返回右上角在线人数弹窗需要的用户列表。 func (h *Handler) listRoomOnlineUsers(writer http.ResponseWriter, request *http.Request) { if h.roomQueryClient == nil { httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error") return } roomID := strings.TrimSpace(request.PathValue("room_id")) if !roomid.ValidStringID(roomID) { httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument") return } page, ok := httpkit.PositiveInt32Query(request, "page", 1) if !ok { httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument") return } pageSize, ok := httpkit.PositiveInt32Query(request, "page_size", 50) if !ok { httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument") return } resp, err := h.roomQueryClient.ListRoomOnlineUsers(request.Context(), &roomv1.ListRoomOnlineUsersRequest{ Meta: httpkit.RoomMeta(request, roomID, ""), RoomId: roomID, ViewerUserId: auth.UserIDFromContext(request.Context()), Page: page, PageSize: pageSize, Sort: strings.TrimSpace(request.URL.Query().Get("sort")), }) if err != nil { httpkit.WriteRPCError(writer, request, err) return } onlineItems := resp.GetItems() userIDs := make([]int64, 0, len(onlineItems)+len(resp.GetUsers())) for _, user := range onlineItems { userIDs = append(userIDs, user.GetUserId()) } if len(onlineItems) == 0 { for _, user := range resp.GetUsers() { userIDs = append(userIDs, user.GetUserId()) } } httpkit.WriteOK(writer, request, roomOnlineUserDataFromProto(resp, h.roomDisplayProfileMap(request, userIDs))) } // listRoomBannedUsers 分页返回房间治理面板使用的当前有效黑名单。 func (h *Handler) listRoomBannedUsers(writer http.ResponseWriter, request *http.Request) { if h.roomQueryClient == nil { httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error") return } roomID := strings.TrimSpace(request.PathValue("room_id")) if !roomid.ValidStringID(roomID) { httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument") return } page, ok := httpkit.PositiveInt32Query(request, "page", 1) if !ok { httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument") return } pageSize, ok := httpkit.PositiveInt32Query(request, "page_size", 20) if !ok { httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument") return } resp, err := h.roomQueryClient.ListRoomBannedUsers(request.Context(), &roomv1.ListRoomBannedUsersRequest{ Meta: httpkit.RoomMeta(request, roomID, ""), RoomId: roomID, ViewerUserId: auth.UserIDFromContext(request.Context()), Page: page, PageSize: pageSize, }) if err != nil { httpkit.WriteRPCError(writer, request, err) return } userIDs := make([]int64, 0, len(resp.GetItems())) for _, item := range resp.GetItems() { userIDs = append(userIDs, item.GetUserId()) } httpkit.WriteOK(writer, request, roomBannedUsersDataFromProto(roomID, resp, h.roomDisplayProfileMap(request, userIDs))) } // getRoomContributionRank 返回房间内用户贡献周期榜。 func (h *Handler) getRoomContributionRank(writer http.ResponseWriter, request *http.Request) { if h.roomQueryClient == nil { httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error") return } roomID := strings.TrimSpace(request.PathValue("room_id")) if !roomid.ValidStringID(roomID) { httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument") return } period := strings.ToLower(strings.TrimSpace(request.URL.Query().Get("period"))) switch period { case "", "day", "week", "month": // 空 period 在 room-service 收敛为 day,方便首屏默认 tab 不重复写参数。 default: httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument") return } limit, ok := httpkit.PositiveInt32Query(request, "limit", 20) if !ok { httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument") return } resp, err := h.roomQueryClient.ListRoomContributionRank(request.Context(), &roomv1.ListRoomContributionRankRequest{ Meta: httpkit.RoomMeta(request, roomID, ""), RoomId: roomID, Period: period, Limit: limit, ViewerUserId: auth.UserIDFromContext(request.Context()), }) if err != nil { httpkit.WriteRPCError(writer, request, err) return } userIDs := make([]int64, 0, len(resp.GetItems())) for _, item := range resp.GetItems() { userIDs = append(userIDs, item.GetUserId()) } httpkit.WriteOK(writer, request, roomContributionRankDataFromProto(resp, h.roomDisplayProfileMap(request, userIDs))) } // getRoomGiftPanel 返回房间送礼面板初始化数据。 func (h *Handler) getRoomGiftPanel(writer http.ResponseWriter, request *http.Request) { h.serveRoomGiftPanel(writer, request) } func (h *Handler) roomGiftPanelBaseConfig(request *http.Request, regionID int64) (roomGiftPanelConfig, error) { app := appcode.FromContext(request.Context()) catalogVersion, cacheable, err := h.roomGiftCatalogVersion(request, app) if err != nil { return roomGiftPanelConfig{}, err } return h.roomGiftPanelBaseConfigForVersion(request, app, regionID, catalogVersion, cacheable) } // roomGiftPanelBaseConfigForVersion 使用已并行取得的 owner 版本加载稳定配置。 // 类型与礼物列表互不依赖,因此 cache miss 时同时查询;区域过滤仍只由 wallet-service 解释。 func (h *Handler) roomGiftPanelBaseConfigForVersion(request *http.Request, app string, regionID int64, catalogVersion int64, cacheable bool) (roomGiftPanelConfig, error) { requestID := httpkit.RequestIDFromContext(request.Context()) roomID := strings.TrimSpace(request.PathValue("room_id")) if cacheable && h.giftPanelCache != nil { cacheStartedAt := time.Now() if cached, ok := h.giftPanelCache.Get(app, regionID, catalogVersion, time.Now()); ok { logRoomGiftPanelStage(request.Context(), requestID, roomID, "config_cache_hit", cacheStartedAt, nil, false) return cached, nil } } // 任一配置 RPC 失败就取消同批 sibling,并在 Wait 后返回;这样总预算取消时不会遗留继续占连接的查询 goroutine。 configCtx, cancelConfig := context.WithCancel(request.Context()) configRequest := request.WithContext(configCtx) var waitGroup sync.WaitGroup var giftTypeResp *walletv1.ListGiftTypeConfigsResponse var giftTypeErr error var giftResp *walletv1.ListGiftConfigsResponse var giftErr error waitGroup.Add(2) go func() { defer waitGroup.Done() startedAt := time.Now() giftTypeResp, giftTypeErr = h.walletClient.ListGiftTypeConfigs(configCtx, &walletv1.ListGiftTypeConfigsRequest{ RequestId: requestID, AppCode: app, ActiveOnly: true, }) logRoomGiftPanelStage(configRequest.Context(), requestID, roomID, "gift_types", startedAt, giftTypeErr, false) if giftTypeErr != nil { cancelConfig() } }() go func() { defer waitGroup.Done() startedAt := time.Now() giftResp, giftErr = h.walletClient.ListGiftConfigs(configCtx, &walletv1.ListGiftConfigsRequest{ RequestId: requestID, AppCode: app, Page: 1, PageSize: 500, ActiveOnly: true, RegionId: regionID, FilterRegion: true, }) logRoomGiftPanelStage(configRequest.Context(), requestID, roomID, "gifts", startedAt, giftErr, false) if giftErr != nil { cancelConfig() } }() waitGroup.Wait() cancelConfig() if giftTypeErr != nil { return roomGiftPanelConfig{}, giftTypeErr } if giftErr != nil { return roomGiftPanelConfig{}, giftErr } giftTypes := make([]giftTypeConfigData, 0, len(giftTypeResp.GetGiftTypes())) for _, item := range giftTypeResp.GetGiftTypes() { giftTypes = append(giftTypes, giftTypeFromProto(item)) } gifts := make([]giftConfigData, 0, len(giftResp.GetGifts())) for _, gift := range giftResp.GetGifts() { gifts = append(gifts, giftFromProto(gift)) } config := roomGiftPanelConfig{ GiftTypes: giftTypes, // 礼物类型禁用后不应出现在房间面板;缓存保存已过滤的稳定物料,背包礼物仍按用户每次实时拼接。 Gifts: filterGiftPanelGifts(gifts, giftTypes), } if cacheable && h.giftPanelCache != nil { h.giftPanelCache.Set(app, regionID, catalogVersion, config, time.Now()) } return config, nil } func (h *Handler) roomGiftCatalogVersion(request *http.Request, app string) (int64, bool, error) { resp, err := h.walletClient.GetGiftCatalogVersion(request.Context(), &walletv1.GetGiftCatalogVersionRequest{ RequestId: httpkit.RequestIDFromContext(request.Context()), AppCode: app, }) if err != nil { if status.Code(err) == codes.Unimplemented { // 滚动发布期间旧 wallet-service 不认识版本 RPC 时,禁用本次配置缓存并回退到原来的直接查询路径。 return 0, false, nil } return 0, false, err } // wallet-service 是礼物目录 owner;gateway 只把 owner 版本当不透明令牌,避免用 TTL 命中跨过后台配置变更。 return resp.GetVersion(), true, nil } func (h *Handler) roomGiftBagGifts(request *http.Request, viewerUserID int64, gifts []giftConfigData) ([]giftConfigData, error) { if viewerUserID <= 0 || h.walletClient == nil || len(gifts) == 0 { return nil, nil } resp, err := h.roomGiftBagResources(request, viewerUserID) if err != nil { return nil, err } return roomGiftBagGiftsFromResources(resp, gifts), nil } func (h *Handler) roomGiftBagResources(request *http.Request, viewerUserID int64) (*walletv1.ListUserResourcesResponse, error) { if viewerUserID <= 0 || h.walletClient == nil { return &walletv1.ListUserResourcesResponse{}, nil } return h.walletClient.ListUserResources(request.Context(), &walletv1.ListUserResourcesRequest{ RequestId: httpkit.RequestIDFromContext(request.Context()), AppCode: appcode.FromContext(request.Context()), UserId: viewerUserID, ResourceType: "gift", ActiveOnly: true, }) } func roomGiftBagGiftsFromResources(resp *walletv1.ListUserResourcesResponse, gifts []giftConfigData) []giftConfigData { if resp == nil || len(gifts) == 0 { return nil } giftByResourceID := make(map[int64]giftConfigData, len(gifts)) for _, gift := range gifts { if gift.ResourceID > 0 { giftByResourceID[gift.ResourceID] = gift } } bagGifts := make([]giftConfigData, 0, len(resp.GetResources())) for _, item := range resp.GetResources() { remaining := item.GetRemainingQuantity() if remaining <= 0 { remaining = item.GetQuantity() } if remaining <= 0 { continue } gift, ok := giftByResourceID[item.GetResourceId()] if !ok { continue } // Bag tab 只承载用户实际拥有的 gift 权益;仍复用 gift 配置的价格/动效,避免背包资源和房间送礼协议出现两套展示口径。 gift.GiftTypeCode = "bag" gift.Source = "bag" gift.EntitlementID = item.GetEntitlementId() gift.OwnedQuantity = item.GetQuantity() gift.RemainingQuantity = remaining bagGifts = append(bagGifts, gift) } return bagGifts } // getRoomRocket 返回房间火箭物料配置和当前进度。 func (h *Handler) getRoomRocket(writer http.ResponseWriter, request *http.Request) { if h.roomQueryClient == nil { httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error") return } roomID := strings.TrimSpace(request.PathValue("room_id")) if !roomid.ValidStringID(roomID) { httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument") return } resp, err := h.roomQueryClient.GetRoomRocket(request.Context(), &roomv1.GetRoomRocketRequest{ Meta: httpkit.RoomMeta(request, roomID, ""), RoomId: roomID, ViewerUserId: auth.UserIDFromContext(request.Context()), }) if err != nil { httpkit.WriteRPCError(writer, request, err) return } resourceGroups, ok := h.resolveRoomRocketResourceGroups(writer, request, resp) if !ok { return } httpkit.WriteOK(writer, request, roomRocketDataFromProto(resp, resourceGroups)) } // resolveRoomRocketResourceGroups 展开奖励池和最近中奖结果引用的资源组。 // room-service 只保存房间状态和奖励资源组 ID;gateway 在 HTTP 出口补齐素材和钱包资产明细,避免 Room Cell 持有展示快照。 func (h *Handler) resolveRoomRocketResourceGroups(writer http.ResponseWriter, request *http.Request, resp *roomv1.GetRoomRocketResponse) (map[int64]*roomRocketResourceGroupData, bool) { ids := roomRocketResourceGroupIDs(resp) groups := make(map[int64]*roomRocketResourceGroupData, len(ids)) if len(ids) == 0 { return groups, true } if h.walletClient == nil { httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error") return nil, false } for _, id := range ids { groupResp, err := h.walletClient.GetResourceGroup(request.Context(), &walletv1.GetResourceGroupRequest{ RequestId: httpkit.RequestIDFromContext(request.Context()), AppCode: appcode.FromContext(request.Context()), GroupId: id, }) if err != nil { httpkit.WriteRPCError(writer, request, err) return nil, false } group := roomRocketResourceGroupFromProto(groupResp.GetGroup()) if group != nil && group.GroupID > 0 { groups[group.GroupID] = group } } return groups, true } // handleRoomFollow 建立或取消当前用户对房间的关注关系。 // 关注关系由 room-service 保存,gateway 只负责鉴权用户和 path room_id 的协议转换。 func (h *Handler) handleRoomFollow(writer http.ResponseWriter, request *http.Request) { if h.roomClient == nil { httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error") return } roomID := strings.TrimSpace(request.PathValue("room_id")) if !roomid.ValidStringID(roomID) { httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument") return } viewerUserID := auth.UserIDFromContext(request.Context()) switch request.Method { case http.MethodPost: resp, err := h.roomClient.FollowRoom(request.Context(), &roomv1.FollowRoomRequest{ Meta: httpkit.RoomMeta(request, roomID, ""), RoomId: roomID, UserId: viewerUserID, }) if err != nil { httpkit.WriteRPCError(writer, request, err) return } httpkit.WriteOK(writer, request, roomFollowDataFromProto(resp)) case http.MethodDelete: resp, err := h.roomClient.UnfollowRoom(request.Context(), &roomv1.UnfollowRoomRequest{ Meta: httpkit.RoomMeta(request, roomID, ""), RoomId: roomID, UserId: viewerUserID, }) if err != nil { httpkit.WriteRPCError(writer, request, err) return } httpkit.WriteOK(writer, request, roomUnfollowDataFromProto(resp)) default: httpkit.WriteError(writer, request, http.StatusMethodNotAllowed, httpkit.CodeInvalidArgument, "invalid argument") } } // createRoom 把创建房间 HTTP JSON 请求转换为 room-service CreateRoom 命令。 func (h *Handler) createRoom(writer http.ResponseWriter, request *http.Request) { var body struct { CommandID string `json:"command_id"` SeatCount int32 `json:"seat_count"` Mode string `json:"mode"` RoomName string `json:"room_name"` RoomAvatar string `json:"room_avatar"` RoomDescription string `json:"room_description"` } if !httpkit.Decode(writer, request, &body) { return } body.CommandID = strings.TrimSpace(body.CommandID) body.Mode = strings.TrimSpace(body.Mode) body.RoomName = strings.TrimSpace(body.RoomName) body.RoomAvatar = strings.TrimSpace(body.RoomAvatar) body.RoomDescription = strings.TrimSpace(body.RoomDescription) if body.CommandID == "" || body.SeatCount < 0 || body.Mode == "" || body.RoomName == "" || body.RoomAvatar == "" { // command_id 必须来自客户端用户动作,seat_count 可省略走后台默认值;mode、room_name 和 room_avatar 是创建页最小必填信息。 httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument") return } if h.userProfileClient == nil { // CreateRoom 的 visible_region_id 必须来自 user-service,不能让客户端传入或 gateway 猜测。 httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error") return } userResp, err := h.userProfileClient.GetUser(request.Context(), &userv1.GetUserRequest{ Meta: httpkit.UserMeta(request, ""), UserId: auth.UserIDFromContext(request.Context()), }) if err != nil { httpkit.WriteRPCError(writer, request, err) return } roomID := appcode.NewScopedID(appcode.FromContext(request.Context())) roomShortID := userResp.GetUser().GetDisplayUserId() resp, err := h.roomClient.CreateRoom(request.Context(), &roomv1.CreateRoomRequest{ Meta: httpkit.RoomMeta(request, roomID, body.CommandID), SeatCount: body.SeatCount, Mode: body.Mode, VisibleRegionId: userResp.GetUser().GetRegionId(), OwnerCountryCode: userResp.GetUser().GetCountry(), RoomName: body.RoomName, RoomAvatar: body.RoomAvatar, RoomDescription: body.RoomDescription, RoomShortId: roomShortID, }) if err != nil { httpkit.WriteRPCError(writer, request, err) return } httpkit.WriteOK(writer, request, createRoomDataFromProto(resp)) } // updateRoomProfile 把 App 房间资料编辑请求转换为 room-service UpdateRoomProfile 命令。 func (h *Handler) updateRoomProfile(writer http.ResponseWriter, request *http.Request) { var body struct { RoomID string `json:"room_id"` CommandID string `json:"command_id"` RoomName *string `json:"room_name"` RoomAvatar *string `json:"room_avatar"` RoomDescription *string `json:"room_description"` SeatCount *int32 `json:"seat_count"` } if !httpkit.Decode(writer, request, &body) { return } body.RoomID = strings.TrimSpace(body.RoomID) body.CommandID = strings.TrimSpace(body.CommandID) trimOptionalString(body.RoomName) trimOptionalString(body.RoomAvatar) trimOptionalString(body.RoomDescription) resp, err := h.roomClient.UpdateRoomProfile(request.Context(), &roomv1.UpdateRoomProfileRequest{ Meta: httpkit.RoomMeta(request, body.RoomID, body.CommandID), RoomName: body.RoomName, RoomAvatar: body.RoomAvatar, RoomDescription: body.RoomDescription, SeatCount: body.SeatCount, }) if err != nil { httpkit.WriteRPCError(writer, request, err) return } httpkit.WriteOK(writer, request, updateRoomProfileDataFromProto(resp)) } // saveRoomBackground 保存房主上传后的背景图 URL,素材归 room-service 按房间维度管理。 func (h *Handler) saveRoomBackground(writer http.ResponseWriter, request *http.Request) { var body struct { RoomID string `json:"room_id"` ImageURL string `json:"image_url"` } if !httpkit.Decode(writer, request, &body) { return } body.RoomID = strings.TrimSpace(body.RoomID) body.ImageURL = strings.TrimSpace(body.ImageURL) if !roomid.ValidStringID(body.RoomID) || body.ImageURL == "" { httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument") return } resp, err := h.roomClient.SaveRoomBackground(request.Context(), &roomv1.SaveRoomBackgroundRequest{ Meta: httpkit.RoomMeta(request, body.RoomID, ""), RoomId: body.RoomID, ImageUrl: body.ImageURL, }) httpkit.Write(writer, request, saveRoomBackgroundDataFromProto(resp), err) } // listRoomBackgrounds 返回房主保存过的背景图素材,以及 Room Cell 快照里的当前生效背景。 func (h *Handler) listRoomBackgrounds(writer http.ResponseWriter, request *http.Request) { if h.roomQueryClient == nil { httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error") return } roomID := strings.TrimSpace(request.PathValue("room_id")) if !roomid.ValidStringID(roomID) { httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument") return } resp, err := h.roomQueryClient.ListRoomBackgrounds(request.Context(), &roomv1.ListRoomBackgroundsRequest{ Meta: httpkit.RoomMeta(request, roomID, ""), RoomId: roomID, ViewerUserId: auth.UserIDFromContext(request.Context()), }) httpkit.Write(writer, request, listRoomBackgroundsDataFromProto(resp), err) } // setRoomBackground 把已保存素材设为当前房间背景;权限和状态写入仍由 Room Cell 串行裁决。 func (h *Handler) setRoomBackground(writer http.ResponseWriter, request *http.Request) { var body struct { RoomID string `json:"room_id"` CommandID string `json:"command_id"` BackgroundID flexibleInt64 `json:"background_id"` } if !httpkit.Decode(writer, request, &body) { return } body.RoomID = strings.TrimSpace(body.RoomID) body.CommandID = strings.TrimSpace(body.CommandID) if !roomid.ValidStringID(body.RoomID) || body.CommandID == "" || int64(body.BackgroundID) <= 0 { httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument") return } resp, err := h.roomClient.SetRoomBackground(request.Context(), &roomv1.SetRoomBackgroundRequest{ Meta: httpkit.RoomMeta(request, body.RoomID, body.CommandID), BackgroundId: int64(body.BackgroundID), }) httpkit.Write(writer, request, setRoomBackgroundDataFromProto(resp), err) } func trimOptionalString(value *string) { if value == nil { return } *value = strings.TrimSpace(*value) } // joinRoom 把进房 HTTP JSON 请求转换为 room-service JoinRoom 命令,并组装房间首屏初始化包。 func (h *Handler) joinRoom(writer http.ResponseWriter, request *http.Request) { var body struct { RoomID string `json:"room_id"` CommandID string `json:"command_id"` Role string `json:"role"` Password string `json:"password"` ResponseMode string `json:"response_mode"` } if !httpkit.Decode(writer, request, &body) { return } responseMode, ok := joinRoomResponseMode(request, body.ResponseMode) if !ok { httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid response_mode") return } actorSnapshot, err := h.resolveRoomActorJoinSnapshot(request) if err != nil { httpkit.WriteRPCError(writer, request, err) return } resp, err := h.roomClient.JoinRoom(request.Context(), &roomv1.JoinRoomRequest{ Meta: httpkit.RoomMeta(request, body.RoomID, body.CommandID), Role: body.Role, Password: body.Password, ActorCountryId: actorSnapshot.countryID, ActorRegionId: actorSnapshot.regionID, ActorDisplayProfile: actorSnapshot.displayProfile, // 座驾快照在进房命令提交前生成;room-service 只保存快照,不回查 wallet。 EntryVehicle: h.joinRoomEntryVehicleSnapshot(request, responseMode), ResponseMode: responseMode, }) if err != nil { httpkit.WriteRPCError(writer, request, err) return } if responseMode == joinRoomResponseModeLite { httpkit.WriteOK(writer, request, h.joinRoomInitialLiteData(request, body.RoomID, resp)) return } httpkit.WriteOK(writer, request, h.joinRoomInitialData(request, body.RoomID, resp)) } func joinRoomResponseMode(request *http.Request, bodyMode string) (string, bool) { mode := strings.TrimSpace(bodyMode) if mode == "" { mode = strings.TrimSpace(request.URL.Query().Get("response_mode")) } if mode == "" { return joinRoomResponseModeFull, true } mode = strings.ToLower(mode) switch mode { case joinRoomResponseModeFull, joinRoomResponseModeLite: return mode, true default: return "", false } } func (h *Handler) joinRoomEntryVehicleSnapshot(request *http.Request, responseMode string) *roomv1.RoomEntryVehicleSnapshot { if responseMode == joinRoomResponseModeLite { // lite 进房只提交 presence 必需事实;座驾属于入场展示增强,后续由资料/装扮接口补齐。 return nil } if h.walletClient == nil { // 座驾只影响进房展示,不应该因为 wallet 暂不可用阻断 JoinRoom。 return nil } userID := auth.UserIDFromContext(request.Context()) if userID <= 0 { return nil } resp, err := h.walletClient.BatchGetUserEquippedResources(request.Context(), &walletv1.BatchGetUserEquippedResourcesRequest{ RequestId: httpkit.RequestIDFromContext(request.Context()), AppCode: appcode.FromContext(request.Context()), UserIds: []int64{userID}, ResourceTypes: []string{"vehicle"}, }) if err != nil { // 佩戴态增强失败时降级为无座驾进房;房间 presence 仍由 room-service 提交。 return nil } for _, user := range resp.GetUsers() { if user.GetUserId() != userID { // batch 响应按用户分组;忽略非当前用户,避免串用户座驾。 continue } for _, item := range user.GetResources() { if item.GetResource().GetResourceType() != "vehicle" { continue } resource := item.GetResource() if resource.GetResourceId() <= 0 { continue } // 这里复制的是资源素材和 entitlement 有效期,不把 wallet 的内部权益结构透传给 room-service。 return &roomv1.RoomEntryVehicleSnapshot{ ResourceId: resource.GetResourceId(), ResourceCode: resource.GetResourceCode(), Name: resource.GetName(), AssetUrl: resource.GetAssetUrl(), PreviewUrl: resource.GetPreviewUrl(), AnimationUrl: resource.GetAnimationUrl(), MetadataJson: resource.GetMetadataJson(), EntitlementId: item.GetEntitlementId(), ExpiresAtMs: item.GetExpiresAtMs(), } } } return nil } func (h *Handler) joinRoomInitialData(request *http.Request, requestedRoomID string, resp *roomv1.JoinRoomResponse) joinRoomData { if resp == nil { return joinRoomData{} } viewerUserID := auth.UserIDFromContext(request.Context()) snapshot := resp.GetRoom() roomID := snapshot.GetRoomId() if roomID == "" { // 老测试桩或异常上游可能只返回 result;用 JoinRoom meta 兜底,避免 HTTP DTO 生成崩溃。 roomID = strings.TrimSpace(requestedRoomID) } return joinRoomData{ Result: commandResultDataFromProto(resp.GetResult()), Room: roomInitialRoomDataFromSnapshot(snapshot, roomID), Viewer: roomViewerDataFromProto(resp.GetUser(), viewerUserID), Seats: roomSeatDataFromSnapshot(snapshot), ContributionRank: roomRankDataFromSnapshot(snapshot, joinRoomContributionRankLimit), Profiles: h.roomInitialProfiles(request, snapshot, viewerUserID), IM: roomIMData{GroupID: roomIMGroupID(roomID), NeedJoinGroup: true}, RTC: h.joinRoomRTCData(roomID, viewerUserID), ServerTimeMS: resp.GetResult().GetServerTimeMs(), } } func (h *Handler) joinRoomInitialLiteData(request *http.Request, requestedRoomID string, resp *roomv1.JoinRoomResponse) joinRoomLiteData { if resp == nil { return joinRoomLiteData{} } viewerUserID := auth.UserIDFromContext(request.Context()) snapshot := resp.GetRoom() roomID := snapshot.GetRoomId() if roomID == "" { // lite 和完整包共用同一兜底,避免异常上游只回 result 时客户端拿不到 IM/RTC 房间 ID。 roomID = strings.TrimSpace(requestedRoomID) } return joinRoomLiteData{ Result: commandResultDataFromProto(resp.GetResult()), Room: roomInitialRoomDataFromSnapshot(snapshot, roomID), Viewer: roomViewerDataFromProto(resp.GetUser(), viewerUserID), Seats: roomSeatDataFromSnapshot(snapshot), Profiles: h.roomInitialBasicProfiles(request, snapshot, viewerUserID), IM: roomIMData{GroupID: roomIMGroupID(roomID), NeedJoinGroup: true}, RTC: h.joinRoomRTCData(roomID, viewerUserID), ServerTimeMS: resp.GetResult().GetServerTimeMs(), } } func (h *Handler) joinRoomRTCData(roomID string, viewerUserID int64) roomRTCData { rtc := roomRTCData{NeedToken: true} token, err := h.generateTencentRTCToken(viewerUserID, roomID) if err != nil { // RTC 票据是进房后的音频能力,不应该回滚已经成功的业务 presence。 rtc.Available = false rtc.Reason = "rtc_token_unavailable" return rtc } rtc.Available = true rtc.Token = &token return rtc } func (h *Handler) roomInitialProfiles(request *http.Request, snapshot *roomv1.RoomSnapshot, viewerUserID int64) []roomDisplayProfileData { userIDs := roomInitialProfileUserIDs(snapshot, viewerUserID, joinRoomContributionRankLimit) // 进房首屏资料和 /users/room-display-profiles:batch 使用同一份聚合逻辑, // 避免头像框、资料卡、徽章等展示资源在不同房间入口字段不一致。 return h.roomDisplayProfiles(request, userIDs) } func (h *Handler) roomInitialBasicProfiles(request *http.Request, snapshot *roomv1.RoomSnapshot, viewerUserID int64) []roomBasicProfileData { userIDs := roomInitialBasicProfileUserIDs(snapshot, viewerUserID) profileMap := h.roomBasicProfileMap(request, userIDs) profiles := make([]roomBasicProfileData, 0, len(userIDs)) for _, userID := range userIDs { if profile, ok := profileMap[userID]; ok { profiles = append(profiles, profile) } } return profiles } func (h *Handler) roomBasicProfileMap(request *http.Request, userIDs []int64) map[int64]roomBasicProfileData { if h.userProfileClient == nil { return nil } userIDs = uniquePositiveUserIDs(userIDs) if len(userIDs) == 0 { return nil } resp, err := h.userProfileClient.BatchGetRoomBasicUsers(request.Context(), &userv1.BatchGetRoomBasicUsersRequest{ Meta: httpkit.UserMeta(request, ""), UserIds: userIDs, }) if err != nil { // lite 首屏资料是展示兜底,失败不能回滚已经成功的进房 presence。 return nil } profiles := make(map[int64]roomBasicProfileData, len(resp.GetUsers())) for _, userID := range userIDs { user := resp.GetUsers()[userID] if user == nil { continue } profiles[userID] = roomBasicProfileData{ UserID: formatOptionalUserID(user.GetUserId()), Username: strings.TrimSpace(user.GetUsername()), Avatar: strings.TrimSpace(user.GetAvatar()), DisplayUserID: strings.TrimSpace(user.GetDisplayUserId()), } } return profiles } // roomHeartbeat 把客户端显式心跳转换为 room-service presence refresh 命令。 func (h *Handler) roomHeartbeat(writer http.ResponseWriter, request *http.Request) { var body struct { RoomID string `json:"room_id"` CommandID string `json:"command_id"` } if !httpkit.Decode(writer, request, &body) { return } resp, err := h.roomClient.RoomHeartbeat(request.Context(), &roomv1.RoomHeartbeatRequest{ Meta: httpkit.RoomMeta(request, body.RoomID, body.CommandID), }) httpkit.Write(writer, request, resp, err) } // leaveRoom 把退房 HTTP JSON 请求转换为 room-service LeaveRoom 命令。 func (h *Handler) leaveRoom(writer http.ResponseWriter, request *http.Request) { var body struct { RoomID string `json:"room_id"` CommandID string `json:"command_id"` } if !httpkit.Decode(writer, request, &body) { return } resp, err := h.roomClient.LeaveRoom(request.Context(), &roomv1.LeaveRoomRequest{ Meta: httpkit.RoomMeta(request, body.RoomID, body.CommandID), }) httpkit.Write(writer, request, resp, err) } // closeRoom 是设计稿右上角电源按钮入口:这里只退出当前用户,不关闭房间生命周期。 func (h *Handler) closeRoom(writer http.ResponseWriter, request *http.Request) { h.leaveRoom(writer, request) } // micUp 把上麦 HTTP JSON 请求转换为 room-service MicUp 命令。 func (h *Handler) micUp(writer http.ResponseWriter, request *http.Request) { var body struct { RoomID string `json:"room_id"` CommandID string `json:"command_id"` SeatNo int32 `json:"seat_no"` } if !httpkit.Decode(writer, request, &body) { return } resp, err := h.roomClient.MicUp(request.Context(), &roomv1.MicUpRequest{ Meta: httpkit.RoomMeta(request, body.RoomID, body.CommandID), SeatNo: body.SeatNo, TargetDisplayProfile: h.resolveRoomUserDisplayProfileBestEffort(request, auth.UserIDFromContext(request.Context())), }) httpkit.Write(writer, request, resp, err) } // micDown 把下麦 HTTP JSON 请求转换为 room-service MicDown 命令。 func (h *Handler) micDown(writer http.ResponseWriter, request *http.Request) { var body struct { RoomID string `json:"room_id"` CommandID string `json:"command_id"` TargetUserID int64 `json:"target_user_id"` Reason string `json:"reason"` } if !httpkit.Decode(writer, request, &body) { return } targetUserID := body.TargetUserID if targetUserID <= 0 { targetUserID = auth.UserIDFromContext(request.Context()) } resp, err := h.roomClient.MicDown(request.Context(), &roomv1.MicDownRequest{ Meta: httpkit.RoomMeta(request, body.RoomID, body.CommandID), TargetUserId: body.TargetUserID, Reason: body.Reason, TargetDisplayProfile: h.resolveRoomUserDisplayProfileBestEffort(request, targetUserID), }) httpkit.Write(writer, request, resp, err) } // changeMicSeat 把换麦位 HTTP JSON 请求转换为 room-service ChangeMicSeat 命令。 func (h *Handler) changeMicSeat(writer http.ResponseWriter, request *http.Request) { var body struct { RoomID string `json:"room_id"` CommandID string `json:"command_id"` TargetUserID int64 `json:"target_user_id"` SeatNo int32 `json:"seat_no"` } if !httpkit.Decode(writer, request, &body) { return } resp, err := h.roomClient.ChangeMicSeat(request.Context(), &roomv1.ChangeMicSeatRequest{ Meta: httpkit.RoomMeta(request, body.RoomID, body.CommandID), TargetUserId: body.TargetUserID, SeatNo: body.SeatNo, TargetDisplayProfile: h.resolveRoomUserDisplayProfileBestEffort(request, body.TargetUserID), }) httpkit.Write(writer, request, resp, err) } // confirmMicPublishing 把客户端 SDK 发流成功回调转换为 room-service 发流确认命令。 func (h *Handler) confirmMicPublishing(writer http.ResponseWriter, request *http.Request) { var body struct { RoomID string `json:"room_id"` CommandID string `json:"command_id"` TargetUserID int64 `json:"target_user_id"` MicSessionID string `json:"mic_session_id"` RoomVersion int64 `json:"room_version"` EventTimeMS int64 `json:"event_time_ms"` Source string `json:"source"` } if !httpkit.Decode(writer, request, &body) { return } targetUserID := body.TargetUserID if targetUserID <= 0 { targetUserID = auth.UserIDFromContext(request.Context()) } resp, err := h.roomClient.ConfirmMicPublishing(request.Context(), &roomv1.ConfirmMicPublishingRequest{ Meta: httpkit.RoomMeta(request, body.RoomID, body.CommandID), TargetUserId: body.TargetUserID, MicSessionId: body.MicSessionID, RoomVersion: body.RoomVersion, EventTimeMs: body.EventTimeMS, Source: body.Source, TargetDisplayProfile: h.resolveRoomUserDisplayProfileBestEffort(request, targetUserID), }) httpkit.Write(writer, request, resp, err) } // micHeartbeat 把客户端麦上心跳转换为 room-service 当前 mic_session 刷新命令。 func (h *Handler) micHeartbeat(writer http.ResponseWriter, request *http.Request) { var body struct { RoomID string `json:"room_id"` CommandID string `json:"command_id"` TargetUserID int64 `json:"target_user_id"` MicSessionID string `json:"mic_session_id"` } if !httpkit.Decode(writer, request, &body) { return } resp, err := h.roomClient.MicHeartbeat(request.Context(), &roomv1.MicHeartbeatRequest{ Meta: httpkit.RoomMeta(request, body.RoomID, body.CommandID), TargetUserId: body.TargetUserID, MicSessionId: body.MicSessionID, }) httpkit.Write(writer, request, resp, err) } // setMicMute 把麦克风静音 HTTP 请求转换为 room-service SetMicMute 命令。 func (h *Handler) setMicMute(writer http.ResponseWriter, request *http.Request) { var body struct { RoomID string `json:"room_id"` CommandID string `json:"command_id"` TargetUserID int64 `json:"target_user_id"` Muted bool `json:"muted"` } if !httpkit.Decode(writer, request, &body) { return } targetUserID := body.TargetUserID if targetUserID <= 0 { targetUserID = auth.UserIDFromContext(request.Context()) } resp, err := h.roomClient.SetMicMute(request.Context(), &roomv1.SetMicMuteRequest{ Meta: httpkit.RoomMeta(request, body.RoomID, body.CommandID), TargetUserId: body.TargetUserID, Muted: body.Muted, TargetDisplayProfile: h.resolveRoomUserDisplayProfileBestEffort(request, targetUserID), }) httpkit.Write(writer, request, resp, err) } // setMicSeatLock 把锁麦 HTTP JSON 请求转换为 room-service SetMicSeatLock 命令。 func (h *Handler) setMicSeatLock(writer http.ResponseWriter, request *http.Request) { var body struct { RoomID string `json:"room_id"` CommandID string `json:"command_id"` SeatNo int32 `json:"seat_no"` Locked bool `json:"locked"` } if !httpkit.Decode(writer, request, &body) { return } resp, err := h.roomClient.SetMicSeatLock(request.Context(), &roomv1.SetMicSeatLockRequest{ Meta: httpkit.RoomMeta(request, body.RoomID, body.CommandID), SeatNo: body.SeatNo, Locked: body.Locked, }) httpkit.Write(writer, request, resp, err) } // setChatEnabled 把公屏开关 HTTP JSON 请求转换为 room-service SetChatEnabled 命令。 func (h *Handler) setChatEnabled(writer http.ResponseWriter, request *http.Request) { var body struct { RoomID string `json:"room_id"` CommandID string `json:"command_id"` Enabled bool `json:"enabled"` } if !httpkit.Decode(writer, request, &body) { return } resp, err := h.roomClient.SetChatEnabled(request.Context(), &roomv1.SetChatEnabledRequest{ Meta: httpkit.RoomMeta(request, body.RoomID, body.CommandID), Enabled: body.Enabled, }) httpkit.Write(writer, request, resp, err) } // setRoomPassword 把房主锁房 HTTP JSON 请求转换为 room-service SetRoomPassword 命令。 func (h *Handler) setRoomPassword(writer http.ResponseWriter, request *http.Request) { var body struct { RoomID string `json:"room_id"` CommandID string `json:"command_id"` Locked bool `json:"locked"` Password string `json:"password"` } if !httpkit.Decode(writer, request, &body) { return } body.RoomID = strings.TrimSpace(body.RoomID) body.CommandID = strings.TrimSpace(body.CommandID) body.Password = strings.TrimSpace(body.Password) resp, err := h.roomClient.SetRoomPassword(request.Context(), &roomv1.SetRoomPasswordRequest{ Meta: httpkit.RoomMeta(request, body.RoomID, body.CommandID), Locked: body.Locked, Password: body.Password, }) if err != nil { httpkit.WriteRPCError(writer, request, err) return } httpkit.WriteOK(writer, request, updateRoomProfileDataFromProto(&roomv1.UpdateRoomProfileResponse{ Result: resp.GetResult(), Room: resp.GetRoom(), })) } // setRoomAdmin 把管理员增删 HTTP JSON 请求转换为 room-service SetRoomAdmin 命令。 func (h *Handler) setRoomAdmin(writer http.ResponseWriter, request *http.Request) { var body struct { RoomID string `json:"room_id"` CommandID string `json:"command_id"` TargetUserID flexibleInt64 `json:"target_user_id"` Enabled bool `json:"enabled"` } if !httpkit.Decode(writer, request, &body) { return } resp, err := h.roomClient.SetRoomAdmin(request.Context(), &roomv1.SetRoomAdminRequest{ Meta: httpkit.RoomMeta(request, body.RoomID, body.CommandID), TargetUserId: int64(body.TargetUserID), Enabled: body.Enabled, }) if err != nil { httpkit.WriteRPCError(writer, request, err) return } roomRole := "normal" if body.Enabled { roomRole = "admin" } httpkit.WriteOK(writer, request, setRoomAdminData{ Result: commandResultDataFromProto(resp.GetResult()), TargetUserID: formatOptionalUserID(int64(body.TargetUserID)), RoomRole: roomRole, ServerTimeMS: resp.GetResult().GetServerTimeMs(), }) } // muteUser 把禁言 HTTP JSON 请求转换为 room-service MuteUser 命令。 func (h *Handler) muteUser(writer http.ResponseWriter, request *http.Request) { var body struct { RoomID string `json:"room_id"` CommandID string `json:"command_id"` TargetUserID int64 `json:"target_user_id"` Muted bool `json:"muted"` } if !httpkit.Decode(writer, request, &body) { return } resp, err := h.roomClient.MuteUser(request.Context(), &roomv1.MuteUserRequest{ Meta: httpkit.RoomMeta(request, body.RoomID, body.CommandID), TargetUserId: body.TargetUserID, Muted: body.Muted, }) httpkit.Write(writer, request, resp, err) } // kickUser 把踢人 HTTP JSON 请求转换为 room-service KickUser 命令。 func (h *Handler) kickUser(writer http.ResponseWriter, request *http.Request) { var body struct { RoomID string `json:"room_id"` CommandID string `json:"command_id"` TargetUserID int64 `json:"target_user_id"` DurationMS int64 `json:"duration_ms"` } if !httpkit.Decode(writer, request, &body) { return } resp, err := h.roomClient.KickUser(request.Context(), &roomv1.KickUserRequest{ Meta: httpkit.RoomMeta(request, body.RoomID, body.CommandID), TargetUserId: body.TargetUserID, DurationMs: body.DurationMS, }) httpkit.Write(writer, request, resp, err) } // unbanUser 把解封 HTTP JSON 请求转换为 room-service UnbanUser 命令。 func (h *Handler) unbanUser(writer http.ResponseWriter, request *http.Request) { var body struct { RoomID string `json:"room_id"` CommandID string `json:"command_id"` TargetUserID int64 `json:"target_user_id"` } if !httpkit.Decode(writer, request, &body) { return } resp, err := h.roomClient.UnbanUser(request.Context(), &roomv1.UnbanUserRequest{ Meta: httpkit.RoomMeta(request, body.RoomID, body.CommandID), TargetUserId: body.TargetUserID, }) httpkit.Write(writer, request, resp, err) } // sendGift 把送礼 HTTP JSON 请求转换为 room-service SendGift 命令。 // 扣费、幂等、房间表现和 outbox 仍由 room-service 的命令链路统一处理。 func (h *Handler) sendGift(writer http.ResponseWriter, request *http.Request) { h.handleSendGift(writer, request, "") } // sendGiftBatch 是新 Flutter 的多人送礼入口。 // 它不改变账务和房间事实,只通过 display_mode=batch 要求 room-service 合并房间展示 IM。 func (h *Handler) sendGiftBatch(writer http.ResponseWriter, request *http.Request) { h.handleSendGift(writer, request, "batch") } func (h *Handler) handleSendGift(writer http.ResponseWriter, request *http.Request, displayMode string) { var body struct { RoomID string `json:"room_id"` CommandID string `json:"command_id"` TargetType string `json:"target_type"` TargetUserID int64 `json:"target_user_id"` TargetUserIDs []int64 `json:"target_user_ids"` GiftID string `json:"gift_id"` GiftCount int32 `json:"gift_count"` PoolID string `json:"pool_id"` EntitlementID string `json:"entitlement_id"` Source string `json:"source"` } if !httpkit.Decode(writer, request, &body) { return } targetType := strings.TrimSpace(body.TargetType) if targetType == "" { targetType = "user" } targetUserIDs := normalizeGiftTargetUserIDs(body.TargetUserID, body.TargetUserIDs) if targetType == "user" && len(targetUserIDs) == 0 { httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument") return } userSnapshots, err := h.resolveGiftUserSnapshots(request, targetUserIDs) if err != nil { httpkit.WriteRPCError(writer, request, err) return } targetHostScopes, err := h.resolveGiftTargetHostScopes(request, targetUserIDs) if err != nil { httpkit.WriteRPCError(writer, request, err) return } firstTargetScope := firstGiftTargetHostScope(targetHostScopes, firstUserID(targetUserIDs)) resp, err := h.roomClient.SendGift(request.Context(), &roomv1.SendGiftRequest{ Meta: httpkit.RoomMeta(request, body.RoomID, body.CommandID), TargetType: targetType, TargetUserIds: targetUserIDs, TargetUserId: firstUserID(targetUserIDs), GiftId: body.GiftID, GiftCount: body.GiftCount, PoolId: body.PoolID, SenderCountryId: userSnapshots.senderCountryID, SenderRegionId: userSnapshots.senderRegionID, TargetIsHost: firstTargetScope.GetTargetIsHost(), TargetHostRegionId: firstTargetScope.GetTargetHostRegionId(), TargetAgencyOwnerUserId: firstTargetScope.GetTargetAgencyOwnerUserId(), TargetHostScopes: targetHostScopes, EntitlementId: strings.TrimSpace(body.EntitlementID), Source: strings.TrimSpace(body.Source), SenderDisplayProfile: userSnapshots.senderDisplayProfile, TargetDisplayProfiles: userSnapshots.targetDisplayProfiles, DisplayMode: strings.TrimSpace(displayMode), }) httpkit.Write(writer, request, resp, err) } type roomActorJoinSnapshot struct { countryID int64 regionID int64 displayProfile *roomv1.RoomUserDisplayProfile } func (h *Handler) resolveRoomActorJoinSnapshot(request *http.Request) (roomActorJoinSnapshot, error) { if h.userProfileClient == nil { return roomActorJoinSnapshot{}, xerr.New(xerr.Unavailable, "user service is not configured") } resp, err := h.userProfileClient.GetUser(request.Context(), &userv1.GetUserRequest{ Meta: httpkit.UserMeta(request, ""), UserId: auth.UserIDFromContext(request.Context()), }) if err != nil { return roomActorJoinSnapshot{}, err } if resp.GetUser() == nil { return roomActorJoinSnapshot{}, xerr.New(xerr.NotFound, "user not found") } if resp.GetUser().GetCountryId() <= 0 || resp.GetUser().GetRegionId() <= 0 { return roomActorJoinSnapshot{}, xerr.New(xerr.InvalidArgument, "user country and region are required") } // room-service 事件必须携带用户真实国家/区域和展示快照;房间 visible_region_id 只表达房间列表归属。 return roomActorJoinSnapshot{ countryID: resp.GetUser().GetCountryId(), regionID: resp.GetUser().GetRegionId(), displayProfile: roomUserDisplayProfileFromUser(resp.GetUser()), }, nil } func (h *Handler) resolveRoomUserDisplayProfileBestEffort(request *http.Request, userID int64) *roomv1.RoomUserDisplayProfile { if h.userProfileClient == nil || userID <= 0 { return nil } resp, err := h.userProfileClient.GetUser(request.Context(), &userv1.GetUserRequest{ Meta: httpkit.UserMeta(request, ""), UserId: userID, }) if err != nil { // 麦位状态提交不能被展示资料查询拖垮;缺快照时客户端仍会异步刷新在线成员资料。 return nil } return roomUserDisplayProfileFromUser(resp.GetUser()) } type giftUserSnapshots struct { senderCountryID int64 senderRegionID int64 senderDisplayProfile *roomv1.SendGiftDisplayProfile targetDisplayProfiles []*roomv1.SendGiftDisplayProfile } func (h *Handler) resolveGiftUserSnapshots(request *http.Request, targetUserIDs []int64) (giftUserSnapshots, error) { if h.userProfileClient == nil { return giftUserSnapshots{}, xerr.ToGRPCError(xerr.New(xerr.Unavailable, "user service is not configured")) } senderUserID := auth.UserIDFromContext(request.Context()) userIDs := uniquePositiveUserIDs(append([]int64{senderUserID}, targetUserIDs...)) resp, err := h.userProfileClient.BatchGetUsers(request.Context(), &userv1.BatchGetUsersRequest{ Meta: httpkit.UserMeta(request, ""), UserIds: userIDs, }) if err != nil { return giftUserSnapshots{}, err } sender := resp.GetUsers()[senderUserID] if sender == nil { return giftUserSnapshots{}, xerr.ToGRPCError(xerr.New(xerr.NotFound, "user not found")) } if sender.GetCountryId() <= 0 || sender.GetRegionId() <= 0 { return giftUserSnapshots{}, xerr.ToGRPCError(xerr.New(xerr.InvalidArgument, "user country and region are required")) } targets := make([]*roomv1.SendGiftDisplayProfile, 0, len(targetUserIDs)) for _, targetUserID := range targetUserIDs { if profile := giftDisplayProfileFromUser(resp.GetUsers()[targetUserID]); profile != nil { // 目标展示快照不是结算前置条件;缺失 target 资料时只让客户端走最终昵称头像兜底。 targets = append(targets, profile) } } return giftUserSnapshots{ senderCountryID: sender.GetCountryId(), senderRegionID: sender.GetRegionId(), senderDisplayProfile: giftDisplayProfileFromUser(sender), targetDisplayProfiles: targets, }, nil } func (h *Handler) resolveGiftTargetHostScope(request *http.Request, targetUserID int64) (bool, int64, int64, error) { if targetUserID <= 0 || h.userHostClient == nil { // 未装配 host client 时送礼仍可继续,但工资周期钻石 fail-closed,避免客户端伪造主播入账。 return false, 0, 0, nil } resp, err := h.userHostClient.GetHostProfile(request.Context(), &userv1.GetHostProfileRequest{ Meta: httpkit.UserMeta(request, ""), UserId: targetUserID, }) if err != nil { if xerr.ReasonFromGRPC(err) == xerr.NotFound { return false, 0, 0, nil } return false, 0, 0, err } profile := resp.GetHostProfile() if profile == nil || !strings.EqualFold(strings.TrimSpace(profile.GetStatus()), "active") || profile.GetRegionId() <= 0 { // 只有 active host 且带区域才能进入工资政策链路;disabled/缺区域按非主播处理,不影响正常送礼。 return false, 0, 0, nil } return true, profile.GetRegionId(), profile.GetCurrentAgencyOwnerUserId(), nil } func (h *Handler) resolveGiftTargetHostScopes(request *http.Request, targetUserIDs []int64) ([]*roomv1.SendGiftTargetHostScope, error) { if len(targetUserIDs) == 0 { return []*roomv1.SendGiftTargetHostScope{}, nil } if h.userHostClient == nil { scopes := make([]*roomv1.SendGiftTargetHostScope, 0, len(targetUserIDs)) for _, targetUserID := range targetUserIDs { // 未装配 host client 时送礼仍可继续,但所有 target 都 fail-closed 为非主播。 scopes = append(scopes, giftTargetHostScopeFromProfile(targetUserID, nil)) } return scopes, nil } resp, err := h.userHostClient.BatchGetHostProfiles(request.Context(), &userv1.BatchGetHostProfilesRequest{ Meta: httpkit.UserMeta(request, ""), UserIds: targetUserIDs, }) if err != nil { if status.Code(err) == codes.Unimplemented { // 滚动发布期间旧 user-service 还没有 batch RPC 时,临时退回单查;其他错误必须阻断送礼。 return h.resolveGiftTargetHostScopesBySingleLookup(request, targetUserIDs) } return nil, err } profiles := resp.GetHostProfiles() scopes := make([]*roomv1.SendGiftTargetHostScope, 0, len(targetUserIDs)) for _, targetUserID := range targetUserIDs { // 批量送礼必须按接收方分别固化主播快照;缺失 profile 表示该 target 当前不是主播。 scopes = append(scopes, giftTargetHostScopeFromProfile(targetUserID, profiles[targetUserID])) } return scopes, nil } func (h *Handler) resolveGiftTargetHostScopesBySingleLookup(request *http.Request, targetUserIDs []int64) ([]*roomv1.SendGiftTargetHostScope, error) { scopes := make([]*roomv1.SendGiftTargetHostScope, 0, len(targetUserIDs)) for _, targetUserID := range targetUserIDs { targetIsHost, targetHostRegionID, targetAgencyOwnerUserID, err := h.resolveGiftTargetHostScope(request, targetUserID) if err != nil { return nil, err } // 批量送礼必须按接收方分别固化主播快照;只解析第一个 target 会让后续主播丢失工资钻石入账。 scopes = append(scopes, &roomv1.SendGiftTargetHostScope{ TargetUserId: targetUserID, TargetIsHost: targetIsHost, TargetHostRegionId: targetHostRegionID, TargetAgencyOwnerUserId: targetAgencyOwnerUserID, }) } return scopes, nil } func giftTargetHostScopeFromProfile(targetUserID int64, profile *userv1.HostProfile) *roomv1.SendGiftTargetHostScope { scope := &roomv1.SendGiftTargetHostScope{TargetUserId: targetUserID} if profile == nil || !strings.EqualFold(strings.TrimSpace(profile.GetStatus()), "active") || profile.GetRegionId() <= 0 { // 只有 active host 且带区域才能进入工资政策链路;disabled/缺区域按非主播处理,不影响正常送礼。 return scope } scope.TargetIsHost = true scope.TargetHostRegionId = profile.GetRegionId() scope.TargetAgencyOwnerUserId = profile.GetCurrentAgencyOwnerUserId() return scope } func giftDisplayProfileFromUser(user *userv1.User) *roomv1.SendGiftDisplayProfile { if user == nil || user.GetUserId() <= 0 { return nil } // 这里不生成 User 兜底;客户端已有最终兜底,后端只传真实用户资料快照。 return &roomv1.SendGiftDisplayProfile{ UserId: user.GetUserId(), Username: strings.TrimSpace(user.GetUsername()), Avatar: strings.TrimSpace(user.GetAvatar()), DisplayUserId: strings.TrimSpace(user.GetDisplayUserId()), PrettyDisplayUserId: strings.TrimSpace(user.GetPrettyDisplayUserId()), } } func roomUserDisplayProfileFromUser(user *userv1.User) *roomv1.RoomUserDisplayProfile { if user == nil || user.GetUserId() <= 0 { return nil } // 这里不生成 User 兜底;真实昵称/头像缺失时让客户端走 display_user_id 或最终 user_id 兜底。 return &roomv1.RoomUserDisplayProfile{ UserId: user.GetUserId(), Nickname: strings.TrimSpace(user.GetUsername()), Avatar: strings.TrimSpace(user.GetAvatar()), DisplayUserId: strings.TrimSpace(user.GetDisplayUserId()), PrettyDisplayUserId: strings.TrimSpace(user.GetPrettyDisplayUserId()), } } func firstGiftTargetHostScope(scopes []*roomv1.SendGiftTargetHostScope, targetUserID int64) *roomv1.SendGiftTargetHostScope { for _, scope := range scopes { if scope.GetTargetUserId() == targetUserID { return scope } } return &roomv1.SendGiftTargetHostScope{TargetUserId: targetUserID} } // batchRoomDisplayProfiles 返回房间和公屏专用展示资料。 func (h *Handler) batchRoomDisplayProfiles(writer http.ResponseWriter, request *http.Request) { if h.userProfileClient == nil { httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error") return } userIDs, ok := parseBatchUserIDs(request) if !ok { httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument") return } httpkit.WriteOK(writer, request, map[string]any{"profiles": h.roomDisplayProfiles(request, userIDs)}) } func parseBatchUserIDs(request *http.Request) ([]int64, bool) { rawValues := append([]string(nil), request.URL.Query()["user_ids"]...) rawValues = append(rawValues, request.URL.Query()["user_id"]...) seen := make(map[int64]bool) result := make([]int64, 0, len(rawValues)) for _, raw := range rawValues { for piece := range strings.SplitSeq(raw, ",") { piece = strings.TrimSpace(piece) if piece == "" { continue } userID, err := strconv.ParseInt(piece, 10, 64) if err != nil || userID <= 0 { return nil, false } if seen[userID] { continue } seen[userID] = true result = append(result, userID) } } return result, len(result) > 0 } func (h *Handler) roomDisplayProfiles(request *http.Request, userIDs []int64) []roomDisplayProfileData { profileMap := h.roomDisplayProfileMap(request, userIDs) profiles := make([]roomDisplayProfileData, 0, len(userIDs)) for _, userID := range userIDs { if profile, ok := profileMap[userID]; ok { profiles = append(profiles, profile) } } return profiles } func (h *Handler) roomDisplayProfileMap(request *http.Request, userIDs []int64) map[int64]roomDisplayProfileData { if h.userProfileClient == nil { return nil } userIDs = uniquePositiveUserIDs(userIDs) if len(userIDs) == 0 { return nil } resp, err := h.userProfileClient.BatchGetUsers(request.Context(), &userv1.BatchGetUsersRequest{ Meta: httpkit.UserMeta(request, ""), UserIds: userIDs, }) if err != nil { // 房间资料是展示增强,失败时保留主房间数据,让客户端后续重试 batch 接口。 return nil } // 房间资料高频读取,头像框/资料卡/座驾/当前佩戴徽章用 wallet 批量查; // activity 的 badge 投影再补展示槽位,两个来源合并后客户端才能同时看到长短徽章。 appearanceMap := h.roomAppearanceResourceMap(request, userIDs) badgeMap := h.roomBadgeDisplayMap(request, userIDs) levelMap := h.roomLevelDisplayProfileMap(request, userIDs) profiles := make(map[int64]roomDisplayProfileData, len(resp.GetUsers())) for _, userID := range userIDs { user := resp.GetUsers()[userID] if user == nil { continue } appearance := appearanceMap[userID] levels := levelMap[userID] profiles[userID] = roomDisplayProfileData{ UserID: formatOptionalUserID(user.GetUserId()), Username: user.GetUsername(), Avatar: user.GetAvatar(), DisplayUserID: user.GetDisplayUserId(), PrettyID: user.GetPrettyId(), PrettyDisplayUserID: user.GetPrettyDisplayUserId(), Gender: strings.TrimSpace(user.GetGender()), Age: roomProfileAgeFromBirth(user.GetBirth(), time.Now().UTC()), Country: strings.ToUpper(strings.TrimSpace(user.GetCountry())), CountryName: strings.TrimSpace(user.GetCountryName()), CountryDisplayName: strings.TrimSpace(user.GetCountryDisplayName()), CountryFlag: roomProfileCountryFlag(user.GetCountry()), VIP: map[string]any{}, Level: levels.Levels, LevelBadges: levels.Badges, Badges: roomMergeBadgeItems(badgeMap[userID], appearance.Badges), AvatarFrame: appearance.AvatarFrame, ProfileCard: appearance.ProfileCard, Vehicle: appearance.Vehicle, MicSeatAnimation: appearance.MicSeatAnimation, Charm: 0, } // 显式补空对象/空数组,避免客户端在房间资料中同时处理 null 和空值。 if profiles[userID].Badges == nil { profile := profiles[userID] profile.Badges = []map[string]any{} profiles[userID] = profile } if profiles[userID].AvatarFrame == nil { profile := profiles[userID] profile.AvatarFrame = map[string]any{} profiles[userID] = profile } if profiles[userID].Level == nil { profile := profiles[userID] profile.Level = roomDefaultLevelDisplay() profiles[userID] = profile } if profiles[userID].LevelBadges == nil { profile := profiles[userID] profile.LevelBadges = map[string]any{} profiles[userID] = profile } if profiles[userID].Vehicle == nil { profile := profiles[userID] profile.Vehicle = map[string]any{} profiles[userID] = profile } if profiles[userID].MicSeatAnimation == nil { profile := profiles[userID] profile.MicSeatAnimation = map[string]any{} profiles[userID] = profile } if profiles[userID].ProfileCard == nil { profile := profiles[userID] profile.ProfileCard = map[string]any{} profiles[userID] = profile } } return profiles } type roomAppearanceResources struct { AvatarFrame map[string]any ProfileCard map[string]any Vehicle map[string]any MicSeatAnimation map[string]any Badges []map[string]any } func (h *Handler) roomAppearanceResourceMap(request *http.Request, userIDs []int64) map[int64]roomAppearanceResources { result := make(map[int64]roomAppearanceResources, len(userIDs)) if h.walletClient == nil || len(userIDs) == 0 { return result } // wallet 在该 RPC 内做过期佩戴过滤,所以 roomDisplayProfileMap 不需要重复判断 expires_at_ms。 resp, err := h.walletClient.BatchGetUserEquippedResources(request.Context(), &walletv1.BatchGetUserEquippedResourcesRequest{ RequestId: httpkit.RequestIDFromContext(request.Context()), AppCode: appcode.FromContext(request.Context()), UserIds: userIDs, ResourceTypes: []string{roomAppearanceResourceAvatarFrame, roomAppearanceResourceProfileCard, roomAppearanceResourceVehicle, roomAppearanceResourceBadge, roomAppearanceResourceMicSeatAnimation}, }) if err != nil { // 装扮是展示增强,失败不影响房间主数据返回。 return result } for _, user := range resp.GetUsers() { if user.GetUserId() <= 0 { continue } current := result[user.GetUserId()] for _, item := range user.GetResources() { // 同一资源类型只保留当前佩戴的一个权益;wallet equipment 主键保证唯一性。 resource := roomAppearanceResourceFromEntitlement(item) switch item.GetResource().GetResourceType() { case roomAppearanceResourceAvatarFrame: current.AvatarFrame = resource case roomAppearanceResourceProfileCard: current.ProfileCard = resource case roomAppearanceResourceVehicle: current.Vehicle = resource case roomAppearanceResourceBadge: current.Badges = append(current.Badges, roomBadgeItemFromEquippedResource(resource)) case roomAppearanceResourceMicSeatAnimation: current.MicSeatAnimation = resource } } result[user.GetUserId()] = current } return result } func roomBadgeItemFromEquippedResource(resource map[string]any) map[string]any { if len(resource) == 0 { return map[string]any{} } return map[string]any{ "slot": "equipped", "position": 0, "badge_form": strings.TrimSpace(fmt.Sprint(resource["badge_form"])), "resource_id": resource["resource_id"], "entitlement_id": strings.TrimSpace(fmt.Sprint(resource["entitlement_id"])), "source_type": "wallet_equipment", "source_id": strings.TrimSpace(fmt.Sprint(resource["resource_code"])), "pin_mode": "equipped", "updated_at_ms": int64(0), "resource": resource, } } func roomMergeBadgeItems(displayBadges []map[string]any, equippedBadges []map[string]any) []map[string]any { if len(displayBadges) == 0 && len(equippedBadges) == 0 { return []map[string]any{} } result := make([]map[string]any, 0, len(displayBadges)+len(equippedBadges)) seen := make(map[string]struct{}, len(displayBadges)+len(equippedBadges)) add := func(item map[string]any) { if len(item) == 0 { return } key := roomBadgeDedupeKey(item) if key != "" { if _, exists := seen[key]; exists { return } seen[key] = struct{}{} } result = append(result, item) } for _, item := range displayBadges { add(item) } for _, item := range equippedBadges { add(item) } return result } func roomBadgeDedupeKey(item map[string]any) string { resourceID := strings.TrimSpace(fmt.Sprint(item["resource_id"])) entitlementID := strings.TrimSpace(fmt.Sprint(item["entitlement_id"])) if resourceID == "" || resourceID == "0" { if resource, ok := item["resource"].(map[string]any); ok { resourceID = strings.TrimSpace(fmt.Sprint(resource["resource_id"])) } } if entitlementID != "" { return "entitlement:" + entitlementID } if resourceID != "" && resourceID != "0" { return "resource:" + resourceID } return "" } type roomLevelDisplayResources struct { Levels map[string]any Badges map[string]any } func (h *Handler) roomLevelDisplayProfileMap(request *http.Request, userIDs []int64) map[int64]roomLevelDisplayResources { result := make(map[int64]roomLevelDisplayResources, len(userIDs)) for _, userID := range userIDs { result[userID] = roomLevelDisplayResources{Levels: roomDefaultLevelDisplay(), Badges: map[string]any{}} } if h.growthLevelClient != nil && len(userIDs) > 0 { resp, err := h.growthLevelClient.BatchGetUserLevelDisplayProfiles(request.Context(), &activityv1.BatchGetUserLevelDisplayProfilesRequest{ Meta: httpkit.ActivityMeta(request), UserIds: userIDs, }) if err == nil { resourceIDs := make([]int64, 0, len(resp.GetProfiles())*3) for _, profile := range resp.GetProfiles() { resourceIDs = append(resourceIDs, profile.GetWealth().GetBadgeResourceId()) resourceIDs = append(resourceIDs, profile.GetGame().GetBadgeResourceId()) resourceIDs = append(resourceIDs, profile.GetCharm().GetBadgeResourceId()) } resources := h.roomResourcesByID(request, resourceIDs) for _, profile := range resp.GetProfiles() { if profile.GetUserId() <= 0 { continue } current := roomLevelDisplayResources{Levels: roomDefaultLevelDisplay(), Badges: map[string]any{}} roomApplyLevelDisplayTrack(current, profile.GetWealth(), resources) roomApplyLevelDisplayTrack(current, profile.GetGame(), resources) roomApplyLevelDisplayTrack(current, profile.GetCharm(), resources) result[profile.GetUserId()] = current } } } h.fillRoomLevelBadgeEntitlementFallback(request, userIDs, result) return result } func (h *Handler) fillRoomLevelBadgeEntitlementFallback(request *http.Request, userIDs []int64, result map[int64]roomLevelDisplayResources) { if h.walletClient == nil || len(userIDs) == 0 { return } for _, userID := range userIDs { current := result[userID] if !roomLevelBadgesNeedFallback(current.Badges) { continue } // wallet 暂无按多用户批量查拥有资源 RPC;这里仅在 activity 投影缺轨道时逐用户读取 active badge 权益。 // 已有投影仍优先返回,避免注册 1 级权益覆盖用户后续升级后的等级徽章。 resp, err := h.walletClient.ListUserResources(request.Context(), &walletv1.ListUserResourcesRequest{ RequestId: httpkit.RequestIDFromContext(request.Context()), AppCode: appcode.FromContext(request.Context()), UserId: userID, ResourceType: "badge", ActiveOnly: true, }) if err != nil { continue } for _, item := range resp.GetResources() { roomApplyLevelBadgeEntitlement(current, item) if !roomLevelBadgesNeedFallback(current.Badges) { break } } result[userID] = current } } func roomLevelBadgesNeedFallback(levelBadges map[string]any) bool { if levelBadges == nil { return true } for _, track := range []string{"wealth", "game", "charm"} { if _, ok := levelBadges[track]; !ok { return true } } return false } func roomApplyLevelBadgeEntitlement(current roomLevelDisplayResources, item *walletv1.UserResourceEntitlement) { if item == nil || current.Badges == nil { return } resource := roomAppearanceResourceFromEntitlement(item) track := strings.TrimSpace(fmt.Sprint(resource["level_track"])) if track == "" || fmt.Sprint(resource["badge_kind"]) != "level" || fmt.Sprint(resource["badge_form"]) != "strip" { return } if _, exists := current.Badges[track]; exists { return } resourceID, _ := resource["resource_id"].(int64) if resourceID <= 0 { return } current.Badges[track] = map[string]any{ "track": track, "source_type": "wallet_entitlement", "source_level": roomLevelValue(current.Levels, track), "badge_kind": "level", "resource_id": resourceID, "entitlement_id": item.GetEntitlementId(), "updated_at_ms": item.GetUpdatedAtMs(), "resource": resource, } } func roomLevelValue(levels map[string]any, track string) int64 { trackLevel, ok := levels[track].(map[string]any) if !ok { return 0 } switch value := trackLevel["level"].(type) { case int64: return value case int32: return int64(value) case int: return int64(value) case float64: return int64(value) default: return 0 } } func roomApplyLevelDisplayTrack(current roomLevelDisplayResources, item *activityv1.LevelDisplayTrackProfile, resources map[int64]map[string]any) { if item == nil || strings.TrimSpace(item.GetTrack()) == "" { return } track := strings.TrimSpace(item.GetTrack()) current.Levels[track] = map[string]any{ "level": item.GetLevel(), "tier_id": item.GetTierId(), "badge_resource_id": item.GetBadgeResourceId(), "badge_source_level": item.GetBadgeSourceLevel(), "avatar_frame_id": item.GetAvatarFrameResourceId(), "updated_at_ms": item.GetUpdatedAtMs(), "display_badge_source": "level_rule", } if item.GetBadgeResourceId() <= 0 { return } badge := map[string]any{ "track": track, "source_type": "level_rule", "source_level": item.GetBadgeSourceLevel(), "badge_kind": "level", "resource_id": item.GetBadgeResourceId(), "updated_at_ms": item.GetUpdatedAtMs(), } if resource, ok := resources[item.GetBadgeResourceId()]; ok { badge["resource"] = resource } current.Badges[track] = badge } func roomDefaultLevelDisplay() map[string]any { return map[string]any{ "wealth": map[string]any{"level": int32(0), "tier_id": int64(0), "badge_resource_id": int64(0), "badge_source_level": int32(0), "avatar_frame_id": int64(0), "updated_at_ms": int64(0), "display_badge_source": "level_rule"}, "game": map[string]any{"level": int32(0), "tier_id": int64(0), "badge_resource_id": int64(0), "badge_source_level": int32(0), "avatar_frame_id": int64(0), "updated_at_ms": int64(0), "display_badge_source": "level_rule"}, "charm": map[string]any{"level": int32(0), "tier_id": int64(0), "badge_resource_id": int64(0), "badge_source_level": int32(0), "avatar_frame_id": int64(0), "updated_at_ms": int64(0), "display_badge_source": "level_rule"}, } } func (h *Handler) roomBadgeDisplayMap(request *http.Request, userIDs []int64) map[int64][]map[string]any { result := make(map[int64][]map[string]any, len(userIDs)) if h.achievementClient == nil || len(userIDs) == 0 { return result } // activity 目前没有 badge 批量接口;这里逐用户读取,但所有 badge 资源素材统一去重后解析。 raw := make(map[int64][]*activityv1.BadgeDisplayItem, len(userIDs)) resourceIDs := make([]int64, 0) for _, userID := range userIDs { resp, err := h.achievementClient.ListMyBadges(request.Context(), &activityv1.ListMyBadgesRequest{ Meta: httpkit.ActivityMeta(request), UserId: userID, }) if err != nil { // 单个用户 badge 投影失败时只影响该用户徽章,不拖垮整个房间资料响应。 continue } items := append([]*activityv1.BadgeDisplayItem{}, resp.GetStripBadges()...) items = append(items, resp.GetProfileTileBadges()...) raw[userID] = items for _, item := range items { resourceIDs = append(resourceIDs, item.GetResourceId()) } } resources := h.roomResourcesByID(request, resourceIDs) for userID, items := range raw { result[userID] = roomBadgeItemsFromProto(items, resources) } return result } func (h *Handler) roomResourcesByID(request *http.Request, ids []int64) map[int64]map[string]any { ids = uniquePositiveUserIDs(ids) result := make(map[int64]map[string]any, len(ids)) if h.walletClient == nil || len(ids) == 0 { return result } // badge display item 只带 resource_id;这里补齐客户端渲染需要的 asset/animation/metadata。 for _, id := range ids { resp, err := h.walletClient.GetResource(request.Context(), &walletv1.GetResourceRequest{ RequestId: httpkit.RequestIDFromContext(request.Context()), AppCode: appcode.FromContext(request.Context()), ResourceId: id, }) if err != nil { // 素材缺失时保留 badge item 本体,客户端至少能根据 resource_id 做兜底展示。 continue } resource := roomAppearanceResourceFromProto(resp.GetResource(), "", 0) if resourceID, ok := resource["resource_id"].(int64); ok && resourceID > 0 { result[resourceID] = resource } } return result } func roomBadgeItemsFromProto(items []*activityv1.BadgeDisplayItem, resources map[int64]map[string]any) []map[string]any { result := make([]map[string]any, 0, len(items)) for _, item := range items { if item == nil { continue } data := map[string]any{ "slot": item.GetSlot(), "position": item.GetPosition(), "badge_form": item.GetBadgeForm(), "resource_id": item.GetResourceId(), "entitlement_id": item.GetEntitlementId(), "source_type": item.GetSourceType(), "source_id": item.GetSourceId(), "pin_mode": item.GetPinMode(), "updated_at_ms": item.GetUpdatedAtMs(), } if resource, ok := resources[item.GetResourceId()]; ok { data["resource"] = resource } result = append(result, data) } return result } func roomAppearanceResourceFromEntitlement(item *walletv1.UserResourceEntitlement) map[string]any { if item == nil { return map[string]any{} } return roomAppearanceResourceFromProto(item.GetResource(), item.GetEntitlementId(), item.GetExpiresAtMs()) } func roomAppearanceResourceFromProto(item *walletv1.Resource, entitlementID string, expiresAtMS int64) map[string]any { if item == nil || item.GetResourceId() <= 0 { return map[string]any{} } resource := map[string]any{ "resource_id": item.GetResourceId(), "resource_code": item.GetResourceCode(), "resource_type": item.GetResourceType(), "name": item.GetName(), "asset_url": item.GetAssetUrl(), "preview_url": item.GetPreviewUrl(), "animation_url": item.GetAnimationUrl(), "metadata_json": item.GetMetadataJson(), "entitlement_id": entitlementID, "expires_at_ms": expiresAtMS, } if item.GetResourceType() == "badge" { badgeForm, badgeKind, levelTrack := roomBadgeMetadataFields(item.GetMetadataJson()) resource["badge_form"] = badgeForm resource["badge_kind"] = badgeKind resource["level_track"] = levelTrack } return resource } func roomBadgeMetadataFields(raw string) (string, string, string) { payload := map[string]any{} if err := json.Unmarshal([]byte(strings.TrimSpace(raw)), &payload); err != nil { return "", "normal", "" } badgeForm := strings.ToLower(strings.TrimSpace(fmt.Sprint(payload["badge_form"]))) if badgeForm != "strip" && badgeForm != "tile" { badgeForm = "" } badgeKind := strings.ToLower(strings.TrimSpace(fmt.Sprint(payload["badge_kind"]))) if badgeKind != "level" { badgeKind = "normal" } levelTrack := strings.ToLower(strings.TrimSpace(fmt.Sprint(payload["level_track"]))) switch levelTrack { case "wealth", "game", "charm": default: levelTrack = "" } return badgeForm, badgeKind, levelTrack } func roomProfileAgeFromBirth(birth string, now time.Time) int32 { birth = strings.TrimSpace(birth) if birth == "" { return 0 } born, err := time.Parse("2006-01-02", birth) if err != nil { return 0 } now = now.UTC() today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC) birthdayThisYear := time.Date(now.Year(), born.Month(), born.Day(), 0, 0, 0, 0, time.UTC) age := now.Year() - born.Year() if today.Before(birthdayThisYear) { age-- } if age < 0 || age > 150 { return 0 } return int32(age) } func roomProfileCountryFlag(country string) string { country = strings.ToUpper(strings.TrimSpace(country)) if len(country) != 2 { return "" } runes := []rune(country) for _, value := range runes { if value < 'A' || value > 'Z' { return "" } } const regionalIndicatorA = 0x1F1E6 return string([]rune{ regionalIndicatorA + (runes[0] - 'A'), regionalIndicatorA + (runes[1] - 'A'), }) } func roomGiftRecipientUserIDs(snapshot *roomv1.RoomSnapshot) []int64 { userIDs := make([]int64, 0, len(snapshot.GetMicSeats())+1) for _, seat := range snapshot.GetMicSeats() { if seat.GetUserId() > 0 { userIDs = append(userIDs, seat.GetUserId()) } } if len(userIDs) == 0 { userIDs = append(userIDs, snapshot.GetOwnerUserId()) } return uniquePositiveUserIDs(userIDs) } func roomGiftRecipients(snapshot *roomv1.RoomSnapshot, profiles map[int64]roomDisplayProfileData) []roomGiftRecipientData { userIDs := roomGiftRecipientUserIDs(snapshot) recipients := make([]roomGiftRecipientData, 0, len(userIDs)+1) if len(userIDs) > 1 { recipients = append(recipients, roomGiftRecipientData{TargetType: "all_mic", Label: "all_mic"}) } seatByUser := make(map[int64]int32, len(snapshot.GetMicSeats())) for _, seat := range snapshot.GetMicSeats() { if seat.GetUserId() > 0 { seatByUser[seat.GetUserId()] = seat.GetSeatNo() } } for _, userID := range userIDs { recipient := roomGiftRecipientData{ TargetType: "user", UserID: formatOptionalUserID(userID), SeatNo: seatByUser[userID], } if profile, ok := profiles[userID]; ok { copied := profile recipient.Profile = &copied recipient.Label = copied.Username } recipients = append(recipients, recipient) } return recipients } func roomGiftTabs(gifts []giftConfigData, giftTypes []giftTypeConfigData) []roomGiftTabData { tabs := []roomGiftTabData{{Key: "all", Order: 0}} hasBag := false if len(giftTypes) > 0 { hasGift := make(map[string]bool, len(gifts)) for _, gift := range gifts { key := strings.TrimSpace(gift.GiftTypeCode) if key == "bag" || strings.TrimSpace(gift.Source) == "bag" { hasBag = true continue } if key != "" { hasGift[key] = true } } for _, giftType := range giftTypes { typeCode := strings.TrimSpace(giftType.TypeCode) if typeCode == "" || !hasGift[typeCode] { continue } tabKey := strings.TrimSpace(giftType.TabKey) if tabKey == "" { tabKey = typeCode } tabs = append(tabs, roomGiftTabData{ Key: typeCode, GiftTypeCode: typeCode, Label: tabKey, Order: giftType.SortOrder, }) } if hasBag { tabs = append(tabs, roomGiftTabData{Key: "bag", GiftTypeCode: "bag", Label: "Bag", Order: 5}) } return tabs } seen := map[string]bool{"all": true} for _, gift := range gifts { key := strings.TrimSpace(gift.GiftTypeCode) if key == "" || seen[key] { continue } seen[key] = true tabs = append(tabs, roomGiftTabData{Key: key, Order: int32(len(tabs) * 10)}) } return tabs } func filterGiftPanelGifts(gifts []giftConfigData, giftTypes []giftTypeConfigData) []giftConfigData { if len(giftTypes) == 0 { return gifts } activeTypes := make(map[string]bool, len(giftTypes)) for _, giftType := range giftTypes { typeCode := strings.TrimSpace(giftType.TypeCode) if typeCode != "" { activeTypes[typeCode] = true } } out := make([]giftConfigData, 0, len(gifts)) for _, gift := range gifts { if activeTypes[strings.TrimSpace(gift.GiftTypeCode)] { out = append(out, gift) } } return out } func coinBalanceFromProto(resp *walletv1.GetBalancesResponse) int64 { for _, balance := range resp.GetBalances() { if balance.GetAssetType() == "COIN" { return balance.GetAvailableAmount() } } return 0 } func uniquePositiveUserIDs(userIDs []int64) []int64 { seen := make(map[int64]bool, len(userIDs)) result := make([]int64, 0, len(userIDs)) for _, userID := range userIDs { if userID <= 0 || seen[userID] { continue } seen[userID] = true result = append(result, userID) } return result } func normalizeGiftTargetUserIDs(targetUserID int64, targetUserIDs []int64) []int64 { result := uniquePositiveUserIDs(targetUserIDs) if len(result) == 0 && targetUserID > 0 { result = append(result, targetUserID) } return result } func firstUserID(userIDs []int64) int64 { if len(userIDs) == 0 { return 0 } return userIDs[0] } func robotRoomProfileUserIDs(snapshot *roomv1.RoomSnapshot) []int64 { if snapshot == nil || snapshot.GetRoomExt()[roomExtRobotRoomKey] != "true" { return nil } return parsePositiveInt64CSV(snapshot.GetRoomExt()[roomExtRobotUserIDsKey]) } func parsePositiveInt64CSV(raw string) []int64 { userIDs := make([]int64, 0) for piece := range strings.SplitSeq(raw, ",") { piece = strings.TrimSpace(piece) if piece == "" { continue } userID, err := strconv.ParseInt(piece, 10, 64) if err != nil || userID <= 0 { continue } userIDs = append(userIDs, userID) } return uniquePositiveUserIDs(userIDs) }