2101 lines
72 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"
"fmt"
"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"
activityv1 "hyapp.local/api/proto/activity/v1"
roomv1 "hyapp.local/api/proto/room/v1"
userv1 "hyapp.local/api/proto/user/v1"
walletv1 "hyapp.local/api/proto/wallet/v1"
)
const (
defaultRoomFeedRelationPageSize = 100
maxRoomFeedRelationScanCount = 500
)
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 == "" || body.RoomAvatar == "" {
// command_id 必须来自客户端用户动作seat_count 可省略走后台默认值mode、room_name 和 room_avatar 是创建页最小必填信息。
httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument")
return
}
if h.userProfileClient == nil {
// CreateRoom 的 visible_region_id 必须来自 user-service不能让客户端传入或 gateway 猜测。
httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
return
}
userResp, err := h.userProfileClient.GetUser(request.Context(), &userv1.GetUserRequest{
Meta: httpkit.UserMeta(request, ""),
UserId: auth.UserIDFromContext(request.Context()),
})
if err != nil {
httpkit.WriteRPCError(writer, request, err)
return
}
roomID := appcode.NewScopedID(appcode.FromContext(request.Context()))
roomShortID := userResp.GetUser().GetDisplayUserId()
resp, err := h.roomClient.CreateRoom(request.Context(), &roomv1.CreateRoomRequest{
Meta: httpkit.RoomMeta(request, roomID, body.CommandID),
SeatCount: body.SeatCount,
Mode: body.Mode,
VisibleRegionId: userResp.GetUser().GetRegionId(),
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,
// 座驾快照在进房命令提交前生成room-service 只保存快照,不回查 wallet。
EntryVehicle: h.joinRoomEntryVehicleSnapshot(request),
})
if err != nil {
httpkit.WriteRPCError(writer, request, err)
return
}
httpkit.WriteOK(writer, request, h.joinRoomInitialData(request, body.RoomID, resp))
}
func (h *Handler) joinRoomEntryVehicleSnapshot(request *http.Request) *roomv1.RoomEntryVehicleSnapshot {
if h.walletClient == nil {
// 座驾只影响进房展示,不应该因为 wallet 暂不可用阻断 JoinRoom。
return nil
}
userID := auth.UserIDFromContext(request.Context())
if userID <= 0 {
return nil
}
resp, err := h.walletClient.BatchGetUserEquippedResources(request.Context(), &walletv1.BatchGetUserEquippedResourcesRequest{
RequestId: httpkit.RequestIDFromContext(request.Context()),
AppCode: appcode.FromContext(request.Context()),
UserIds: []int64{userID},
ResourceTypes: []string{"vehicle"},
})
if err != nil {
// 佩戴态增强失败时降级为无座驾进房;房间 presence 仍由 room-service 提交。
return nil
}
for _, user := range resp.GetUsers() {
if user.GetUserId() != userID {
// batch 响应按用户分组;忽略非当前用户,避免串用户座驾。
continue
}
for _, item := range user.GetResources() {
if item.GetResource().GetResourceType() != "vehicle" {
continue
}
resource := item.GetResource()
if resource.GetResourceId() <= 0 {
continue
}
// 这里复制的是资源素材和 entitlement 有效期,不把 wallet 的内部权益结构透传给 room-service。
return &roomv1.RoomEntryVehicleSnapshot{
ResourceId: resource.GetResourceId(),
ResourceCode: resource.GetResourceCode(),
Name: resource.GetName(),
AssetUrl: resource.GetAssetUrl(),
PreviewUrl: resource.GetPreviewUrl(),
AnimationUrl: resource.GetAnimationUrl(),
MetadataJson: resource.GetMetadataJson(),
EntitlementId: item.GetEntitlementId(),
ExpiresAtMs: item.GetExpiresAtMs(),
}
}
}
return nil
}
func (h *Handler) joinRoomInitialData(request *http.Request, requestedRoomID string, resp *roomv1.JoinRoomResponse) joinRoomData {
if resp == nil {
return joinRoomData{}
}
viewerUserID := auth.UserIDFromContext(request.Context())
snapshot := resp.GetRoom()
roomID := snapshot.GetRoomId()
if roomID == "" {
// 老测试桩或异常上游可能只返回 result用 JoinRoom meta 兜底,避免 HTTP DTO 生成崩溃。
roomID = strings.TrimSpace(requestedRoomID)
}
return joinRoomData{
Result: commandResultDataFromProto(resp.GetResult()),
Room: roomInitialRoomDataFromSnapshot(snapshot, roomID),
Viewer: roomViewerDataFromProto(resp.GetUser(), viewerUserID),
Seats: roomSeatDataFromSnapshot(snapshot),
ContributionRank: roomRankDataFromSnapshot(snapshot, joinRoomContributionRankLimit),
Profiles: h.roomInitialProfiles(request, snapshot, viewerUserID),
IM: roomIMData{GroupID: roomIMGroupID(roomID), NeedJoinGroup: true},
RTC: h.joinRoomRTCData(roomID, viewerUserID),
ServerTimeMS: resp.GetResult().GetServerTimeMs(),
}
}
func (h *Handler) joinRoomRTCData(roomID string, viewerUserID int64) roomRTCData {
rtc := roomRTCData{NeedToken: true}
token, err := h.generateTencentRTCToken(viewerUserID, roomID)
if err != nil {
// RTC 票据是进房后的音频能力,不应该回滚已经成功的业务 presence。
rtc.Available = false
rtc.Reason = "rtc_token_unavailable"
return rtc
}
rtc.Available = true
rtc.Token = &token
return rtc
}
func (h *Handler) roomInitialProfiles(request *http.Request, snapshot *roomv1.RoomSnapshot, viewerUserID int64) []roomDisplayProfileData {
userIDs := roomInitialProfileUserIDs(snapshot, viewerUserID, joinRoomContributionRankLimit)
// 进房首屏资料和 /users/room-display-profiles:batch 使用同一份聚合逻辑,
// 避免头像框、资料卡、徽章等展示资源在不同房间入口字段不一致。
return h.roomDisplayProfiles(request, userIDs)
}
// 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)
}
// micHeartbeat 把客户端麦上心跳转换为 room-service 当前 mic_session 刷新命令。
func (h *Handler) micHeartbeat(writer http.ResponseWriter, request *http.Request) {
var body struct {
RoomID string `json:"room_id"`
CommandID string `json:"command_id"`
TargetUserID int64 `json:"target_user_id"`
MicSessionID string `json:"mic_session_id"`
}
if !httpkit.Decode(writer, request, &body) {
return
}
resp, err := h.roomClient.MicHeartbeat(request.Context(), &roomv1.MicHeartbeatRequest{
Meta: httpkit.RoomMeta(request, body.RoomID, body.CommandID),
TargetUserId: body.TargetUserID,
MicSessionId: body.MicSessionID,
})
httpkit.Write(writer, request, resp, err)
}
// setMicMute 把麦克风静音 HTTP 请求转换为 room-service SetMicMute 命令。
func (h *Handler) setMicMute(writer http.ResponseWriter, request *http.Request) {
var body struct {
RoomID string `json:"room_id"`
CommandID string `json:"command_id"`
TargetUserID int64 `json:"target_user_id"`
Muted bool `json:"muted"`
}
if !httpkit.Decode(writer, request, &body) {
return
}
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) == 0 {
httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument")
return
}
senderRegionID, err := h.resolveGiftSenderRegionID(request)
if err != nil {
httpkit.WriteRPCError(writer, request, err)
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,
SenderRegionId: senderRegionID,
TargetIsHost: targetIsHost,
TargetHostRegionId: targetHostRegionID,
TargetAgencyOwnerUserId: targetAgencyOwnerUserID,
})
httpkit.Write(writer, request, resp, err)
}
func (h *Handler) resolveGiftSenderRegionID(request *http.Request) (int64, error) {
if h.userProfileClient == nil {
return 0, xerr.New(xerr.Unavailable, "user service is not configured")
}
resp, err := h.userProfileClient.GetUser(request.Context(), &userv1.GetUserRequest{
Meta: httpkit.UserMeta(request, ""),
UserId: auth.UserIDFromContext(request.Context()),
})
if err != nil {
return 0, err
}
if resp.GetUser() == nil {
return 0, xerr.New(xerr.NotFound, "sender not found")
}
return resp.GetUser().GetRegionId(), nil
}
func (h *Handler) resolveGiftTargetHostScope(request *http.Request, targetUserID int64) (bool, int64, int64, error) {
if targetUserID <= 0 || h.userHostClient == nil {
// 未装配 host client 时送礼仍可继续,但工资周期钻石 fail-closed避免客户端伪造主播入账。
return false, 0, 0, nil
}
resp, err := h.userHostClient.GetHostProfile(request.Context(), &userv1.GetHostProfileRequest{
Meta: httpkit.UserMeta(request, ""),
UserId: targetUserID,
})
if err != nil {
if xerr.ReasonFromGRPC(err) == xerr.NotFound {
return false, 0, 0, nil
}
return false, 0, 0, err
}
profile := resp.GetHostProfile()
if profile == nil || !strings.EqualFold(strings.TrimSpace(profile.GetStatus()), "active") || profile.GetRegionId() <= 0 {
// 只有 active host 且带区域才能进入工资政策链路disabled/缺区域按非主播处理,不影响正常送礼。
return false, 0, 0, nil
}
return true, profile.GetRegionId(), profile.GetCurrentAgencyOwnerUserId(), nil
}
// batchRoomDisplayProfiles 返回房间和公屏专用展示资料。
func (h *Handler) batchRoomDisplayProfiles(writer http.ResponseWriter, request *http.Request) {
if h.userProfileClient == nil {
httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
return
}
userIDs, ok := parseBatchUserIDs(request)
if !ok {
httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument")
return
}
httpkit.WriteOK(writer, request, map[string]any{"profiles": h.roomDisplayProfiles(request, userIDs)})
}
func parseBatchUserIDs(request *http.Request) ([]int64, bool) {
rawValues := append([]string(nil), request.URL.Query()["user_ids"]...)
rawValues = append(rawValues, request.URL.Query()["user_id"]...)
seen := make(map[int64]bool)
result := make([]int64, 0, len(rawValues))
for _, raw := range rawValues {
for piece := range strings.SplitSeq(raw, ",") {
piece = strings.TrimSpace(piece)
if piece == "" {
continue
}
userID, err := strconv.ParseInt(piece, 10, 64)
if err != nil || userID <= 0 {
return nil, false
}
if seen[userID] {
continue
}
seen[userID] = true
result = append(result, userID)
}
}
return result, len(result) > 0
}
func (h *Handler) roomDisplayProfiles(request *http.Request, userIDs []int64) []roomDisplayProfileData {
profileMap := h.roomDisplayProfileMap(request, userIDs)
profiles := make([]roomDisplayProfileData, 0, len(userIDs))
for _, userID := range userIDs {
if profile, ok := profileMap[userID]; ok {
profiles = append(profiles, profile)
}
}
return profiles
}
func (h *Handler) roomDisplayProfileMap(request *http.Request, userIDs []int64) map[int64]roomDisplayProfileData {
if h.userProfileClient == nil {
return nil
}
userIDs = uniquePositiveUserIDs(userIDs)
if len(userIDs) == 0 {
return nil
}
resp, err := h.userProfileClient.BatchGetUsers(request.Context(), &userv1.BatchGetUsersRequest{
Meta: httpkit.UserMeta(request, ""),
UserIds: userIDs,
})
if err != nil {
// 房间资料是展示增强,失败时保留主房间数据,让客户端后续重试 batch 接口。
return nil
}
// 房间资料高频读取,头像框/资料卡/座驾/当前佩戴徽章用 wallet 批量查;
// activity 的 badge 投影再补展示槽位,两个来源合并后客户端才能同时看到长短徽章。
appearanceMap := h.roomAppearanceResourceMap(request, userIDs)
badgeMap := h.roomBadgeDisplayMap(request, userIDs)
levelMap := h.roomLevelDisplayProfileMap(request, userIDs)
profiles := make(map[int64]roomDisplayProfileData, len(resp.GetUsers()))
for _, userID := range userIDs {
user := resp.GetUsers()[userID]
if user == nil {
continue
}
appearance := appearanceMap[userID]
levels := levelMap[userID]
profiles[userID] = roomDisplayProfileData{
UserID: formatOptionalUserID(user.GetUserId()),
Username: user.GetUsername(),
Avatar: user.GetAvatar(),
DisplayUserID: user.GetDisplayUserId(),
Gender: strings.TrimSpace(user.GetGender()),
Age: roomProfileAgeFromBirth(user.GetBirth(), time.Now().UTC()),
Country: strings.ToUpper(strings.TrimSpace(user.GetCountry())),
CountryName: strings.TrimSpace(user.GetCountryName()),
CountryDisplayName: strings.TrimSpace(user.GetCountryDisplayName()),
CountryFlag: roomProfileCountryFlag(user.GetCountry()),
VIP: map[string]any{},
Level: levels.Levels,
LevelBadges: levels.Badges,
Badges: roomMergeBadgeItems(badgeMap[userID], appearance.Badges),
AvatarFrame: appearance.AvatarFrame,
ProfileCard: appearance.ProfileCard,
Vehicle: appearance.Vehicle,
Charm: 0,
}
// 显式补空对象/空数组,避免客户端在房间资料中同时处理 null 和空值。
if profiles[userID].Badges == nil {
profile := profiles[userID]
profile.Badges = []map[string]any{}
profiles[userID] = profile
}
if profiles[userID].AvatarFrame == nil {
profile := profiles[userID]
profile.AvatarFrame = map[string]any{}
profiles[userID] = profile
}
if profiles[userID].Level == nil {
profile := profiles[userID]
profile.Level = roomDefaultLevelDisplay()
profiles[userID] = profile
}
if profiles[userID].LevelBadges == nil {
profile := profiles[userID]
profile.LevelBadges = map[string]any{}
profiles[userID] = profile
}
if profiles[userID].Vehicle == nil {
profile := profiles[userID]
profile.Vehicle = map[string]any{}
profiles[userID] = profile
}
if profiles[userID].ProfileCard == nil {
profile := profiles[userID]
profile.ProfileCard = map[string]any{}
profiles[userID] = profile
}
}
return profiles
}
type roomAppearanceResources struct {
AvatarFrame map[string]any
ProfileCard map[string]any
Vehicle map[string]any
Badges []map[string]any
}
func (h *Handler) roomAppearanceResourceMap(request *http.Request, userIDs []int64) map[int64]roomAppearanceResources {
result := make(map[int64]roomAppearanceResources, len(userIDs))
if h.walletClient == nil || len(userIDs) == 0 {
return result
}
// wallet 在该 RPC 内做过期佩戴过滤,所以 roomDisplayProfileMap 不需要重复判断 expires_at_ms。
resp, err := h.walletClient.BatchGetUserEquippedResources(request.Context(), &walletv1.BatchGetUserEquippedResourcesRequest{
RequestId: httpkit.RequestIDFromContext(request.Context()),
AppCode: appcode.FromContext(request.Context()),
UserIds: userIDs,
ResourceTypes: []string{"avatar_frame", "profile_card", "vehicle", "badge"},
})
if err != nil {
// 装扮是展示增强,失败不影响房间主数据返回。
return result
}
for _, user := range resp.GetUsers() {
if user.GetUserId() <= 0 {
continue
}
current := result[user.GetUserId()]
for _, item := range user.GetResources() {
// 同一资源类型只保留当前佩戴的一个权益wallet equipment 主键保证唯一性。
resource := roomAppearanceResourceFromEntitlement(item)
switch item.GetResource().GetResourceType() {
case "avatar_frame":
current.AvatarFrame = resource
case "profile_card":
current.ProfileCard = resource
case "vehicle":
current.Vehicle = resource
case "badge":
current.Badges = append(current.Badges, roomBadgeItemFromEquippedResource(resource))
}
}
result[user.GetUserId()] = current
}
return result
}
func roomBadgeItemFromEquippedResource(resource map[string]any) map[string]any {
if len(resource) == 0 {
return map[string]any{}
}
return map[string]any{
"slot": "equipped",
"position": 0,
"badge_form": strings.TrimSpace(fmt.Sprint(resource["badge_form"])),
"resource_id": resource["resource_id"],
"entitlement_id": strings.TrimSpace(fmt.Sprint(resource["entitlement_id"])),
"source_type": "wallet_equipment",
"source_id": strings.TrimSpace(fmt.Sprint(resource["resource_code"])),
"pin_mode": "equipped",
"updated_at_ms": int64(0),
"resource": resource,
}
}
func roomMergeBadgeItems(displayBadges []map[string]any, equippedBadges []map[string]any) []map[string]any {
if len(displayBadges) == 0 && len(equippedBadges) == 0 {
return []map[string]any{}
}
result := make([]map[string]any, 0, len(displayBadges)+len(equippedBadges))
seen := make(map[string]struct{}, len(displayBadges)+len(equippedBadges))
add := func(item map[string]any) {
if len(item) == 0 {
return
}
key := roomBadgeDedupeKey(item)
if key != "" {
if _, exists := seen[key]; exists {
return
}
seen[key] = struct{}{}
}
result = append(result, item)
}
for _, item := range displayBadges {
add(item)
}
for _, item := range equippedBadges {
add(item)
}
return result
}
func roomBadgeDedupeKey(item map[string]any) string {
resourceID := strings.TrimSpace(fmt.Sprint(item["resource_id"]))
entitlementID := strings.TrimSpace(fmt.Sprint(item["entitlement_id"]))
if resourceID == "" || resourceID == "0" {
if resource, ok := item["resource"].(map[string]any); ok {
resourceID = strings.TrimSpace(fmt.Sprint(resource["resource_id"]))
}
}
if entitlementID != "" {
return "entitlement:" + entitlementID
}
if resourceID != "" && resourceID != "0" {
return "resource:" + resourceID
}
return ""
}
type roomLevelDisplayResources struct {
Levels map[string]any
Badges map[string]any
}
func (h *Handler) roomLevelDisplayProfileMap(request *http.Request, userIDs []int64) map[int64]roomLevelDisplayResources {
result := make(map[int64]roomLevelDisplayResources, len(userIDs))
for _, userID := range userIDs {
result[userID] = roomLevelDisplayResources{Levels: roomDefaultLevelDisplay(), Badges: map[string]any{}}
}
if h.growthLevelClient != nil && len(userIDs) > 0 {
resp, err := h.growthLevelClient.BatchGetUserLevelDisplayProfiles(request.Context(), &activityv1.BatchGetUserLevelDisplayProfilesRequest{
Meta: httpkit.ActivityMeta(request),
UserIds: userIDs,
})
if err == nil {
resourceIDs := make([]int64, 0, len(resp.GetProfiles())*3)
for _, profile := range resp.GetProfiles() {
resourceIDs = append(resourceIDs, profile.GetWealth().GetBadgeResourceId())
resourceIDs = append(resourceIDs, profile.GetGame().GetBadgeResourceId())
resourceIDs = append(resourceIDs, profile.GetCharm().GetBadgeResourceId())
}
resources := h.roomResourcesByID(request, resourceIDs)
for _, profile := range resp.GetProfiles() {
if profile.GetUserId() <= 0 {
continue
}
current := roomLevelDisplayResources{Levels: roomDefaultLevelDisplay(), Badges: map[string]any{}}
roomApplyLevelDisplayTrack(current, profile.GetWealth(), resources)
roomApplyLevelDisplayTrack(current, profile.GetGame(), resources)
roomApplyLevelDisplayTrack(current, profile.GetCharm(), resources)
result[profile.GetUserId()] = current
}
}
}
h.fillRoomLevelBadgeEntitlementFallback(request, userIDs, result)
return result
}
func (h *Handler) fillRoomLevelBadgeEntitlementFallback(request *http.Request, userIDs []int64, result map[int64]roomLevelDisplayResources) {
if h.walletClient == nil || len(userIDs) == 0 {
return
}
for _, userID := range userIDs {
current := result[userID]
if !roomLevelBadgesNeedFallback(current.Badges) {
continue
}
// wallet 暂无按多用户批量查拥有资源 RPC这里仅在 activity 投影缺轨道时逐用户读取 active badge 权益。
// 已有投影仍优先返回,避免注册 1 级权益覆盖用户后续升级后的等级徽章。
resp, err := h.walletClient.ListUserResources(request.Context(), &walletv1.ListUserResourcesRequest{
RequestId: httpkit.RequestIDFromContext(request.Context()),
AppCode: appcode.FromContext(request.Context()),
UserId: userID,
ResourceType: "badge",
ActiveOnly: true,
})
if err != nil {
continue
}
for _, item := range resp.GetResources() {
roomApplyLevelBadgeEntitlement(current, item)
if !roomLevelBadgesNeedFallback(current.Badges) {
break
}
}
result[userID] = current
}
}
func roomLevelBadgesNeedFallback(levelBadges map[string]any) bool {
if levelBadges == nil {
return true
}
for _, track := range []string{"wealth", "game", "charm"} {
if _, ok := levelBadges[track]; !ok {
return true
}
}
return false
}
func roomApplyLevelBadgeEntitlement(current roomLevelDisplayResources, item *walletv1.UserResourceEntitlement) {
if item == nil || current.Badges == nil {
return
}
resource := roomAppearanceResourceFromEntitlement(item)
track := strings.TrimSpace(fmt.Sprint(resource["level_track"]))
if track == "" || fmt.Sprint(resource["badge_kind"]) != "level" || fmt.Sprint(resource["badge_form"]) != "strip" {
return
}
if _, exists := current.Badges[track]; exists {
return
}
resourceID, _ := resource["resource_id"].(int64)
if resourceID <= 0 {
return
}
current.Badges[track] = map[string]any{
"track": track,
"source_type": "wallet_entitlement",
"source_level": roomLevelValue(current.Levels, track),
"badge_kind": "level",
"resource_id": resourceID,
"entitlement_id": item.GetEntitlementId(),
"updated_at_ms": item.GetUpdatedAtMs(),
"resource": resource,
}
}
func roomLevelValue(levels map[string]any, track string) int64 {
trackLevel, ok := levels[track].(map[string]any)
if !ok {
return 0
}
switch value := trackLevel["level"].(type) {
case int64:
return value
case int32:
return int64(value)
case int:
return int64(value)
case float64:
return int64(value)
default:
return 0
}
}
func roomApplyLevelDisplayTrack(current roomLevelDisplayResources, item *activityv1.LevelDisplayTrackProfile, resources map[int64]map[string]any) {
if item == nil || strings.TrimSpace(item.GetTrack()) == "" {
return
}
track := strings.TrimSpace(item.GetTrack())
current.Levels[track] = map[string]any{
"level": item.GetLevel(),
"tier_id": item.GetTierId(),
"badge_resource_id": item.GetBadgeResourceId(),
"badge_source_level": item.GetBadgeSourceLevel(),
"avatar_frame_id": item.GetAvatarFrameResourceId(),
"updated_at_ms": item.GetUpdatedAtMs(),
"display_badge_source": "level_rule",
}
if item.GetBadgeResourceId() <= 0 {
return
}
badge := map[string]any{
"track": track,
"source_type": "level_rule",
"source_level": item.GetBadgeSourceLevel(),
"badge_kind": "level",
"resource_id": item.GetBadgeResourceId(),
"updated_at_ms": item.GetUpdatedAtMs(),
}
if resource, ok := resources[item.GetBadgeResourceId()]; ok {
badge["resource"] = resource
}
current.Badges[track] = badge
}
func roomDefaultLevelDisplay() map[string]any {
return map[string]any{
"wealth": map[string]any{"level": int32(0), "tier_id": int64(0), "badge_resource_id": int64(0), "badge_source_level": int32(0), "avatar_frame_id": int64(0), "updated_at_ms": int64(0), "display_badge_source": "level_rule"},
"game": map[string]any{"level": int32(0), "tier_id": int64(0), "badge_resource_id": int64(0), "badge_source_level": int32(0), "avatar_frame_id": int64(0), "updated_at_ms": int64(0), "display_badge_source": "level_rule"},
"charm": map[string]any{"level": int32(0), "tier_id": int64(0), "badge_resource_id": int64(0), "badge_source_level": int32(0), "avatar_frame_id": int64(0), "updated_at_ms": int64(0), "display_badge_source": "level_rule"},
}
}
func (h *Handler) roomBadgeDisplayMap(request *http.Request, userIDs []int64) map[int64][]map[string]any {
result := make(map[int64][]map[string]any, len(userIDs))
if h.achievementClient == nil || len(userIDs) == 0 {
return result
}
// activity 目前没有 badge 批量接口;这里逐用户读取,但所有 badge 资源素材统一去重后解析。
raw := make(map[int64][]*activityv1.BadgeDisplayItem, len(userIDs))
resourceIDs := make([]int64, 0)
for _, userID := range userIDs {
resp, err := h.achievementClient.ListMyBadges(request.Context(), &activityv1.ListMyBadgesRequest{
Meta: httpkit.ActivityMeta(request),
UserId: userID,
})
if err != nil {
// 单个用户 badge 投影失败时只影响该用户徽章,不拖垮整个房间资料响应。
continue
}
items := append([]*activityv1.BadgeDisplayItem{}, resp.GetStripBadges()...)
items = append(items, resp.GetProfileTileBadges()...)
raw[userID] = items
for _, item := range items {
resourceIDs = append(resourceIDs, item.GetResourceId())
}
}
resources := h.roomResourcesByID(request, resourceIDs)
for userID, items := range raw {
result[userID] = roomBadgeItemsFromProto(items, resources)
}
return result
}
func (h *Handler) roomResourcesByID(request *http.Request, ids []int64) map[int64]map[string]any {
ids = uniquePositiveUserIDs(ids)
result := make(map[int64]map[string]any, len(ids))
if h.walletClient == nil || len(ids) == 0 {
return result
}
// badge display item 只带 resource_id这里补齐客户端渲染需要的 asset/animation/metadata。
for _, id := range ids {
resp, err := h.walletClient.GetResource(request.Context(), &walletv1.GetResourceRequest{
RequestId: httpkit.RequestIDFromContext(request.Context()),
AppCode: appcode.FromContext(request.Context()),
ResourceId: id,
})
if err != nil {
// 素材缺失时保留 badge item 本体,客户端至少能根据 resource_id 做兜底展示。
continue
}
resource := roomAppearanceResourceFromProto(resp.GetResource(), "", 0)
if resourceID, ok := resource["resource_id"].(int64); ok && resourceID > 0 {
result[resourceID] = resource
}
}
return result
}
func roomBadgeItemsFromProto(items []*activityv1.BadgeDisplayItem, resources map[int64]map[string]any) []map[string]any {
result := make([]map[string]any, 0, len(items))
for _, item := range items {
if item == nil {
continue
}
data := map[string]any{
"slot": item.GetSlot(),
"position": item.GetPosition(),
"badge_form": item.GetBadgeForm(),
"resource_id": item.GetResourceId(),
"entitlement_id": item.GetEntitlementId(),
"source_type": item.GetSourceType(),
"source_id": item.GetSourceId(),
"pin_mode": item.GetPinMode(),
"updated_at_ms": item.GetUpdatedAtMs(),
}
if resource, ok := resources[item.GetResourceId()]; ok {
data["resource"] = resource
}
result = append(result, data)
}
return result
}
func roomAppearanceResourceFromEntitlement(item *walletv1.UserResourceEntitlement) map[string]any {
if item == nil {
return map[string]any{}
}
return roomAppearanceResourceFromProto(item.GetResource(), item.GetEntitlementId(), item.GetExpiresAtMs())
}
func roomAppearanceResourceFromProto(item *walletv1.Resource, entitlementID string, expiresAtMS int64) map[string]any {
if item == nil || item.GetResourceId() <= 0 {
return map[string]any{}
}
resource := map[string]any{
"resource_id": item.GetResourceId(),
"resource_code": item.GetResourceCode(),
"resource_type": item.GetResourceType(),
"name": item.GetName(),
"asset_url": item.GetAssetUrl(),
"preview_url": item.GetPreviewUrl(),
"animation_url": item.GetAnimationUrl(),
"metadata_json": item.GetMetadataJson(),
"entitlement_id": entitlementID,
"expires_at_ms": expiresAtMS,
}
if item.GetResourceType() == "badge" {
badgeForm, badgeKind, levelTrack := roomBadgeMetadataFields(item.GetMetadataJson())
resource["badge_form"] = badgeForm
resource["badge_kind"] = badgeKind
resource["level_track"] = levelTrack
}
return resource
}
func roomBadgeMetadataFields(raw string) (string, string, string) {
payload := map[string]any{}
if err := json.Unmarshal([]byte(strings.TrimSpace(raw)), &payload); err != nil {
return "", "normal", ""
}
badgeForm := strings.ToLower(strings.TrimSpace(fmt.Sprint(payload["badge_form"])))
if badgeForm != "strip" && badgeForm != "tile" {
badgeForm = ""
}
badgeKind := strings.ToLower(strings.TrimSpace(fmt.Sprint(payload["badge_kind"])))
if badgeKind != "level" {
badgeKind = "normal"
}
levelTrack := strings.ToLower(strings.TrimSpace(fmt.Sprint(payload["level_track"])))
switch levelTrack {
case "wealth", "game", "charm":
default:
levelTrack = ""
}
return badgeForm, badgeKind, levelTrack
}
func roomProfileAgeFromBirth(birth string, now time.Time) int32 {
birth = strings.TrimSpace(birth)
if birth == "" {
return 0
}
born, err := time.Parse("2006-01-02", birth)
if err != nil {
return 0
}
now = now.UTC()
today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC)
birthdayThisYear := time.Date(now.Year(), born.Month(), born.Day(), 0, 0, 0, 0, time.UTC)
age := now.Year() - born.Year()
if today.Before(birthdayThisYear) {
age--
}
if age < 0 || age > 150 {
return 0
}
return int32(age)
}
func roomProfileCountryFlag(country string) string {
country = strings.ToUpper(strings.TrimSpace(country))
if len(country) != 2 {
return ""
}
runes := []rune(country)
for _, value := range runes {
if value < 'A' || value > 'Z' {
return ""
}
}
const regionalIndicatorA = 0x1F1E6
return string([]rune{
regionalIndicatorA + (runes[0] - 'A'),
regionalIndicatorA + (runes[1] - 'A'),
})
}
func roomGiftRecipientUserIDs(snapshot *roomv1.RoomSnapshot) []int64 {
userIDs := make([]int64, 0, len(snapshot.GetMicSeats())+1)
for _, seat := range snapshot.GetMicSeats() {
if seat.GetUserId() > 0 {
userIDs = append(userIDs, seat.GetUserId())
}
}
if len(userIDs) == 0 {
userIDs = append(userIDs, snapshot.GetOwnerUserId())
}
return uniquePositiveUserIDs(userIDs)
}
func roomGiftRecipients(snapshot *roomv1.RoomSnapshot, profiles map[int64]roomDisplayProfileData) []roomGiftRecipientData {
userIDs := roomGiftRecipientUserIDs(snapshot)
recipients := make([]roomGiftRecipientData, 0, len(userIDs)+1)
if len(userIDs) > 1 {
recipients = append(recipients, roomGiftRecipientData{TargetType: "all_mic", Label: "all_mic"})
}
seatByUser := make(map[int64]int32, len(snapshot.GetMicSeats()))
for _, seat := range snapshot.GetMicSeats() {
if seat.GetUserId() > 0 {
seatByUser[seat.GetUserId()] = seat.GetSeatNo()
}
}
for _, userID := range userIDs {
recipient := roomGiftRecipientData{
TargetType: "user",
UserID: formatOptionalUserID(userID),
SeatNo: seatByUser[userID],
}
if profile, ok := profiles[userID]; ok {
copied := profile
recipient.Profile = &copied
recipient.Label = copied.Username
}
recipients = append(recipients, recipient)
}
return recipients
}
func roomGiftTabs(gifts []giftConfigData, giftTypes []giftTypeConfigData) []roomGiftTabData {
tabs := []roomGiftTabData{{Key: "all", Order: 0}}
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]
}