2026-05-29 19:56:14 +08:00

1577 lines
54 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

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

package 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_idpresence 和麦位状态由 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]
}