package roomapi import ( "encoding/base64" "encoding/json" "hyapp/services/gateway-service/internal/transport/http/httpkit" "net/http" "strconv" "strings" "time" "hyapp/pkg/appcode" "hyapp/pkg/roomid" "hyapp/pkg/xerr" "hyapp/services/gateway-service/internal/auth" 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 ) 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) { if h.userProfileClient == nil || h.roomQueryClient == nil { // 列表入口必须同时依赖 user-service 区域归属和 room-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 := parsePublicRoomListTab(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 { // user-service 是 region_id 的唯一 owner,查询失败不能用 IP 国家兜底。 httpkit.WriteRPCError(writer, request, err) return } resp, err := h.roomQueryClient.ListRooms(request.Context(), &roomv1.ListRoomsRequest{ Meta: httpkit.RoomMeta(request, "", ""), ViewerUserId: viewerUserID, VisibleRegionId: userResp.GetUser().GetRegionId(), Tab: tab, Cursor: request.URL.Query().Get("cursor"), Limit: limit, Query: roomListQuery(request), }) if err != nil { httpkit.WriteRPCError(writer, request, err) return } httpkit.WriteOK(writer, request, roomListDataFromProto(resp)) } // 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) 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")) } // 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))) } // getRoomGiftPanel 返回房间送礼面板初始化数据。 func (h *Handler) getRoomGiftPanel(writer http.ResponseWriter, request *http.Request) { if h.roomQueryClient == nil || h.walletClient == 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()) snapshotResp, 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 } snapshot := snapshotResp.GetRoom() balances, err := h.walletClient.GetBalances(request.Context(), &walletv1.GetBalancesRequest{ RequestId: httpkit.RequestIDFromContext(request.Context()), UserId: viewerUserID, AssetTypes: []string{"COIN"}, AppCode: appcode.FromContext(request.Context()), }) if err != nil { httpkit.WriteRPCError(writer, request, err) return } giftTypeResp, err := h.walletClient.ListGiftTypeConfigs(request.Context(), &walletv1.ListGiftTypeConfigsRequest{ RequestId: httpkit.RequestIDFromContext(request.Context()), AppCode: appcode.FromContext(request.Context()), ActiveOnly: true, }) if err != nil { httpkit.WriteRPCError(writer, request, err) return } giftTypes := make([]giftTypeConfigData, 0, len(giftTypeResp.GetGiftTypes())) for _, item := range giftTypeResp.GetGiftTypes() { giftTypes = append(giftTypes, giftTypeFromProto(item)) } giftResp, err := h.walletClient.ListGiftConfigs(request.Context(), &walletv1.ListGiftConfigsRequest{ RequestId: httpkit.RequestIDFromContext(request.Context()), AppCode: appcode.FromContext(request.Context()), Page: 1, PageSize: 500, ActiveOnly: true, RegionId: snapshot.GetVisibleRegionId(), FilterRegion: true, }) if err != nil { httpkit.WriteRPCError(writer, request, err) return } gifts := make([]giftConfigData, 0, len(giftResp.GetGifts())) for _, gift := range giftResp.GetGifts() { gifts = append(gifts, giftFromProto(gift)) } gifts = filterGiftPanelGifts(gifts, giftTypes) recipientProfiles := h.roomDisplayProfileMap(request, roomGiftRecipientUserIDs(snapshot)) httpkit.WriteOK(writer, request, roomGiftPanelData{ CoinBalance: coinBalanceFromProto(balances), Recipients: roomGiftRecipients(snapshot, recipientProfiles), Tabs: roomGiftTabs(gifts, giftTypes), Gifts: gifts, QuantityPresets: []int32{1, 9, 99, 999}, }) } // getRoomTreasure 返回房间宝箱物料配置和当前进度。 func (h *Handler) getRoomTreasure(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.GetRoomTreasure(request.Context(), &roomv1.GetRoomTreasureRequest{ Meta: httpkit.RoomMeta(request, roomID, ""), RoomId: roomID, ViewerUserId: auth.UserIDFromContext(request.Context()), }) if err != nil { httpkit.WriteRPCError(writer, request, err) return } httpkit.WriteOK(writer, request, roomTreasureDataFromProto(resp)) } // 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 == "" { // command_id 必须来自客户端用户动作,seat_count 可省略走后台默认值;mode 和 room_name 仍是创建页最小必填信息。 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(), 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"` } if !httpkit.Decode(writer, request, &body) { return } resp, err := h.roomClient.JoinRoom(request.Context(), &roomv1.JoinRoomRequest{ Meta: httpkit.RoomMeta(request, body.RoomID, body.CommandID), Role: body.Role, Password: body.Password, }) if err != nil { httpkit.WriteRPCError(writer, request, err) return } httpkit.WriteOK(writer, request, h.joinRoomInitialData(request, body.RoomID, resp)) } 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) 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) []roomUserProfileData { if h.userProfileClient == nil { return nil } userIDs := roomInitialProfileUserIDs(snapshot, viewerUserID, joinRoomContributionRankLimit) if len(userIDs) == 0 { return nil } resp, err := h.userProfileClient.BatchGetUsers(request.Context(), &userv1.BatchGetUsersRequest{ Meta: httpkit.UserMeta(request, ""), UserIds: userIDs, }) if err != nil { // 用户资料只服务首屏展示,失败时让客户端用 /users/profiles:batch 补偿,避免阻塞进房。 return nil } profiles := make([]roomUserProfileData, 0, len(userIDs)) for _, userID := range userIDs { user := resp.GetUsers()[userID] if user == nil { continue } profiles = append(profiles, roomUserProfileData{ UserID: strconv.FormatInt(user.GetUserId(), 10), DisplayUserID: user.GetDisplayUserId(), Username: user.GetUsername(), Avatar: user.GetAvatar(), }) } 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, }) 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 } resp, err := h.roomClient.MicDown(request.Context(), &roomv1.MicDownRequest{ Meta: httpkit.RoomMeta(request, body.RoomID, body.CommandID), TargetUserId: body.TargetUserID, Reason: body.Reason, }) 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, }) 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 } 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, }) 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 } resp, err := h.roomClient.SetMicMute(request.Context(), &roomv1.SetMicMuteRequest{ Meta: httpkit.RoomMeta(request, body.RoomID, body.CommandID), TargetUserId: body.TargetUserID, Muted: body.Muted, }) 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) { 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"` } 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) != 1 { httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument") return } targetIsHost, targetHostRegionID, targetAgencyOwnerUserID, err := h.resolveGiftTargetHostScope(request, firstUserID(targetUserIDs)) if err != nil { httpkit.WriteRPCError(writer, request, err) return } 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, TargetIsHost: targetIsHost, TargetHostRegionId: targetHostRegionID, TargetAgencyOwnerUserId: targetAgencyOwnerUserID, }) httpkit.Write(writer, request, resp, err) } 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 } // 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 } profiles := make(map[int64]roomDisplayProfileData, len(resp.GetUsers())) for _, userID := range userIDs { user := resp.GetUsers()[userID] if user == nil { continue } profiles[userID] = roomDisplayProfileData{ UserID: formatOptionalUserID(user.GetUserId()), Username: user.GetUsername(), Avatar: user.GetAvatar(), DisplayUserID: user.GetDisplayUserId(), 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: map[string]any{}, Badges: []map[string]any{}, AvatarFrame: map[string]any{}, Vehicle: map[string]any{}, Charm: 0, } } return profiles } 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}} if len(giftTypes) > 0 { hasGift := make(map[string]bool, len(gifts)) for _, gift := range gifts { key := strings.TrimSpace(gift.GiftTypeCode) 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, }) } 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] }