2101 lines
72 KiB
Go
2101 lines
72 KiB
Go
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_id;presence 和麦位状态由 room-service 用读模型和 Room Cell 快照判断。
|
||
func (h *Handler) getCurrentRoom(writer http.ResponseWriter, request *http.Request) {
|
||
if h.roomQueryClient == nil {
|
||
httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
|
||
return
|
||
}
|
||
|
||
resp, err := h.roomQueryClient.GetCurrentRoom(request.Context(), &roomv1.GetCurrentRoomRequest{
|
||
Meta: httpkit.RoomMeta(request, "", ""),
|
||
UserId: auth.UserIDFromContext(request.Context()),
|
||
})
|
||
if err != nil {
|
||
httpkit.WriteRPCError(writer, request, err)
|
||
return
|
||
}
|
||
httpkit.WriteOK(writer, request, currentRoomDataFromProto(resp))
|
||
}
|
||
|
||
// currentRoomData 是 gateway 的外部 JSON 契约,显式保留 false/0 字段。
|
||
// 不能直接把 protobuf response 写出去,否则 proto3 的 omitempty 会让 has_current_room=false 消失。
|
||
type currentRoomData struct {
|
||
HasCurrentRoom bool `json:"has_current_room"`
|
||
RoomID string `json:"room_id"`
|
||
RoomVersion int64 `json:"room_version"`
|
||
Role string `json:"role"`
|
||
MicSessionID string `json:"mic_session_id"`
|
||
PublishState string `json:"publish_state"`
|
||
NeedJoinIMGroup bool `json:"need_join_im_group"`
|
||
NeedRTCToken bool `json:"need_rtc_token"`
|
||
ServerTimeMS int64 `json:"server_time_ms"`
|
||
}
|
||
|
||
func currentRoomDataFromProto(resp *roomv1.GetCurrentRoomResponse) currentRoomData {
|
||
if resp == nil {
|
||
return currentRoomData{}
|
||
}
|
||
|
||
return currentRoomData{
|
||
HasCurrentRoom: resp.GetHasCurrentRoom(),
|
||
RoomID: resp.GetRoomId(),
|
||
RoomVersion: resp.GetRoomVersion(),
|
||
Role: resp.GetRole(),
|
||
MicSessionID: resp.GetMicSessionId(),
|
||
PublishState: resp.GetPublishState(),
|
||
NeedJoinIMGroup: resp.GetNeedJoinImGroup(),
|
||
NeedRTCToken: resp.GetNeedRtcToken(),
|
||
ServerTimeMS: resp.GetServerTimeMs(),
|
||
}
|
||
}
|
||
|
||
// getRoomSnapshot 主动拉取房间页完整快照。
|
||
// 该接口只读 Room Cell/snapshot,不刷新 heartbeat,也不隐式 JoinRoom。
|
||
func (h *Handler) getRoomSnapshot(writer http.ResponseWriter, request *http.Request) {
|
||
if h.roomQueryClient == nil {
|
||
httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
|
||
return
|
||
}
|
||
roomID := strings.TrimSpace(request.URL.Query().Get("room_id"))
|
||
if !roomid.ValidStringID(roomID) {
|
||
httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument")
|
||
return
|
||
}
|
||
|
||
resp, err := h.roomQueryClient.GetRoomSnapshot(request.Context(), &roomv1.GetRoomSnapshotRequest{
|
||
Meta: httpkit.RoomMeta(request, roomID, ""),
|
||
RoomId: roomID,
|
||
ViewerUserId: auth.UserIDFromContext(request.Context()),
|
||
})
|
||
httpkit.Write(writer, request, roomSnapshotDataFromProto(resp), err)
|
||
}
|
||
|
||
// getRoomDetail 返回语音房详情页首屏可直接渲染的数据。
|
||
// 它是 GetRoomSnapshot 的 App 聚合版:补 viewer 权限、IM/RTC 入房参数和首屏展示资料。
|
||
func (h *Handler) getRoomDetail(writer http.ResponseWriter, request *http.Request) {
|
||
if h.roomQueryClient == nil {
|
||
httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
|
||
return
|
||
}
|
||
roomID := strings.TrimSpace(request.PathValue("room_id"))
|
||
if !roomid.ValidStringID(roomID) {
|
||
httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument")
|
||
return
|
||
}
|
||
|
||
viewerUserID := auth.UserIDFromContext(request.Context())
|
||
resp, err := h.roomQueryClient.GetRoomSnapshot(request.Context(), &roomv1.GetRoomSnapshotRequest{
|
||
Meta: httpkit.RoomMeta(request, roomID, ""),
|
||
RoomId: roomID,
|
||
ViewerUserId: viewerUserID,
|
||
})
|
||
if err != nil {
|
||
httpkit.WriteRPCError(writer, request, err)
|
||
return
|
||
}
|
||
profiles := h.roomDisplayProfiles(request, roomInitialProfileUserIDs(resp.GetRoom(), viewerUserID, joinRoomContributionRankLimit))
|
||
httpkit.WriteOK(writer, request, roomDetailDataFromSnapshot(resp, viewerUserID, h.joinRoomRTCData(roomID, viewerUserID), profiles))
|
||
}
|
||
|
||
// listRoomOnlineUsers 分页返回右上角在线人数弹窗需要的用户列表。
|
||
func (h *Handler) listRoomOnlineUsers(writer http.ResponseWriter, request *http.Request) {
|
||
if h.roomQueryClient == nil {
|
||
httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
|
||
return
|
||
}
|
||
roomID := strings.TrimSpace(request.PathValue("room_id"))
|
||
if !roomid.ValidStringID(roomID) {
|
||
httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument")
|
||
return
|
||
}
|
||
page, ok := httpkit.PositiveInt32Query(request, "page", 1)
|
||
if !ok {
|
||
httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument")
|
||
return
|
||
}
|
||
pageSize, ok := httpkit.PositiveInt32Query(request, "page_size", 50)
|
||
if !ok {
|
||
httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument")
|
||
return
|
||
}
|
||
|
||
resp, err := h.roomQueryClient.ListRoomOnlineUsers(request.Context(), &roomv1.ListRoomOnlineUsersRequest{
|
||
Meta: httpkit.RoomMeta(request, roomID, ""),
|
||
RoomId: roomID,
|
||
ViewerUserId: auth.UserIDFromContext(request.Context()),
|
||
Page: page,
|
||
PageSize: pageSize,
|
||
Sort: strings.TrimSpace(request.URL.Query().Get("sort")),
|
||
})
|
||
if err != nil {
|
||
httpkit.WriteRPCError(writer, request, err)
|
||
return
|
||
}
|
||
onlineItems := resp.GetItems()
|
||
userIDs := make([]int64, 0, len(onlineItems)+len(resp.GetUsers()))
|
||
for _, user := range onlineItems {
|
||
userIDs = append(userIDs, user.GetUserId())
|
||
}
|
||
if len(onlineItems) == 0 {
|
||
for _, user := range resp.GetUsers() {
|
||
userIDs = append(userIDs, user.GetUserId())
|
||
}
|
||
}
|
||
httpkit.WriteOK(writer, request, roomOnlineUserDataFromProto(resp, h.roomDisplayProfileMap(request, userIDs)))
|
||
}
|
||
|
||
// listRoomBannedUsers 分页返回房间治理面板使用的当前有效黑名单。
|
||
func (h *Handler) listRoomBannedUsers(writer http.ResponseWriter, request *http.Request) {
|
||
if h.roomQueryClient == nil {
|
||
httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
|
||
return
|
||
}
|
||
roomID := strings.TrimSpace(request.PathValue("room_id"))
|
||
if !roomid.ValidStringID(roomID) {
|
||
httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument")
|
||
return
|
||
}
|
||
page, ok := httpkit.PositiveInt32Query(request, "page", 1)
|
||
if !ok {
|
||
httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument")
|
||
return
|
||
}
|
||
pageSize, ok := httpkit.PositiveInt32Query(request, "page_size", 20)
|
||
if !ok {
|
||
httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument")
|
||
return
|
||
}
|
||
|
||
resp, err := h.roomQueryClient.ListRoomBannedUsers(request.Context(), &roomv1.ListRoomBannedUsersRequest{
|
||
Meta: httpkit.RoomMeta(request, roomID, ""),
|
||
RoomId: roomID,
|
||
ViewerUserId: auth.UserIDFromContext(request.Context()),
|
||
Page: page,
|
||
PageSize: pageSize,
|
||
})
|
||
if err != nil {
|
||
httpkit.WriteRPCError(writer, request, err)
|
||
return
|
||
}
|
||
userIDs := make([]int64, 0, len(resp.GetItems()))
|
||
for _, item := range resp.GetItems() {
|
||
userIDs = append(userIDs, item.GetUserId())
|
||
}
|
||
httpkit.WriteOK(writer, request, roomBannedUsersDataFromProto(roomID, resp, h.roomDisplayProfileMap(request, userIDs)))
|
||
}
|
||
|
||
// getRoomGiftPanel 返回房间送礼面板初始化数据。
|
||
func (h *Handler) getRoomGiftPanel(writer http.ResponseWriter, request *http.Request) {
|
||
if h.roomQueryClient == nil || h.walletClient == nil {
|
||
httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
|
||
return
|
||
}
|
||
roomID := strings.TrimSpace(request.PathValue("room_id"))
|
||
if !roomid.ValidStringID(roomID) {
|
||
httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument")
|
||
return
|
||
}
|
||
|
||
viewerUserID := auth.UserIDFromContext(request.Context())
|
||
snapshotResp, err := h.roomQueryClient.GetRoomSnapshot(request.Context(), &roomv1.GetRoomSnapshotRequest{
|
||
Meta: httpkit.RoomMeta(request, roomID, ""),
|
||
RoomId: roomID,
|
||
ViewerUserId: viewerUserID,
|
||
})
|
||
if err != nil {
|
||
httpkit.WriteRPCError(writer, request, err)
|
||
return
|
||
}
|
||
snapshot := snapshotResp.GetRoom()
|
||
balances, err := h.walletClient.GetBalances(request.Context(), &walletv1.GetBalancesRequest{
|
||
RequestId: httpkit.RequestIDFromContext(request.Context()),
|
||
UserId: viewerUserID,
|
||
AssetTypes: []string{"COIN"},
|
||
AppCode: appcode.FromContext(request.Context()),
|
||
})
|
||
if err != nil {
|
||
httpkit.WriteRPCError(writer, request, err)
|
||
return
|
||
}
|
||
giftTypeResp, err := h.walletClient.ListGiftTypeConfigs(request.Context(), &walletv1.ListGiftTypeConfigsRequest{
|
||
RequestId: httpkit.RequestIDFromContext(request.Context()),
|
||
AppCode: appcode.FromContext(request.Context()),
|
||
ActiveOnly: true,
|
||
})
|
||
if err != nil {
|
||
httpkit.WriteRPCError(writer, request, err)
|
||
return
|
||
}
|
||
giftTypes := make([]giftTypeConfigData, 0, len(giftTypeResp.GetGiftTypes()))
|
||
for _, item := range giftTypeResp.GetGiftTypes() {
|
||
giftTypes = append(giftTypes, giftTypeFromProto(item))
|
||
}
|
||
giftResp, err := h.walletClient.ListGiftConfigs(request.Context(), &walletv1.ListGiftConfigsRequest{
|
||
RequestId: httpkit.RequestIDFromContext(request.Context()),
|
||
AppCode: appcode.FromContext(request.Context()),
|
||
Page: 1,
|
||
PageSize: 500,
|
||
ActiveOnly: true,
|
||
RegionId: snapshot.GetVisibleRegionId(),
|
||
FilterRegion: true,
|
||
})
|
||
if err != nil {
|
||
httpkit.WriteRPCError(writer, request, err)
|
||
return
|
||
}
|
||
gifts := make([]giftConfigData, 0, len(giftResp.GetGifts()))
|
||
for _, gift := range giftResp.GetGifts() {
|
||
gifts = append(gifts, giftFromProto(gift))
|
||
}
|
||
gifts = filterGiftPanelGifts(gifts, giftTypes)
|
||
recipientProfiles := h.roomDisplayProfileMap(request, roomGiftRecipientUserIDs(snapshot))
|
||
httpkit.WriteOK(writer, request, roomGiftPanelData{
|
||
CoinBalance: coinBalanceFromProto(balances),
|
||
Recipients: roomGiftRecipients(snapshot, recipientProfiles),
|
||
Tabs: roomGiftTabs(gifts, giftTypes),
|
||
Gifts: gifts,
|
||
QuantityPresets: []int32{1, 9, 99, 999},
|
||
})
|
||
}
|
||
|
||
// getRoomTreasure 返回房间宝箱物料配置和当前进度。
|
||
func (h *Handler) getRoomTreasure(writer http.ResponseWriter, request *http.Request) {
|
||
if h.roomQueryClient == nil {
|
||
httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
|
||
return
|
||
}
|
||
roomID := strings.TrimSpace(request.PathValue("room_id"))
|
||
if !roomid.ValidStringID(roomID) {
|
||
httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument")
|
||
return
|
||
}
|
||
resp, err := h.roomQueryClient.GetRoomTreasure(request.Context(), &roomv1.GetRoomTreasureRequest{
|
||
Meta: httpkit.RoomMeta(request, roomID, ""),
|
||
RoomId: roomID,
|
||
ViewerUserId: auth.UserIDFromContext(request.Context()),
|
||
})
|
||
if err != nil {
|
||
httpkit.WriteRPCError(writer, request, err)
|
||
return
|
||
}
|
||
httpkit.WriteOK(writer, request, roomTreasureDataFromProto(resp))
|
||
}
|
||
|
||
// handleRoomFollow 建立或取消当前用户对房间的关注关系。
|
||
// 关注关系由 room-service 保存,gateway 只负责鉴权用户和 path room_id 的协议转换。
|
||
func (h *Handler) handleRoomFollow(writer http.ResponseWriter, request *http.Request) {
|
||
if h.roomClient == nil {
|
||
httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
|
||
return
|
||
}
|
||
roomID := strings.TrimSpace(request.PathValue("room_id"))
|
||
if !roomid.ValidStringID(roomID) {
|
||
httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument")
|
||
return
|
||
}
|
||
|
||
viewerUserID := auth.UserIDFromContext(request.Context())
|
||
switch request.Method {
|
||
case http.MethodPost:
|
||
resp, err := h.roomClient.FollowRoom(request.Context(), &roomv1.FollowRoomRequest{
|
||
Meta: httpkit.RoomMeta(request, roomID, ""),
|
||
RoomId: roomID,
|
||
UserId: viewerUserID,
|
||
})
|
||
if err != nil {
|
||
httpkit.WriteRPCError(writer, request, err)
|
||
return
|
||
}
|
||
httpkit.WriteOK(writer, request, roomFollowDataFromProto(resp))
|
||
case http.MethodDelete:
|
||
resp, err := h.roomClient.UnfollowRoom(request.Context(), &roomv1.UnfollowRoomRequest{
|
||
Meta: httpkit.RoomMeta(request, roomID, ""),
|
||
RoomId: roomID,
|
||
UserId: viewerUserID,
|
||
})
|
||
if err != nil {
|
||
httpkit.WriteRPCError(writer, request, err)
|
||
return
|
||
}
|
||
httpkit.WriteOK(writer, request, roomUnfollowDataFromProto(resp))
|
||
default:
|
||
httpkit.WriteError(writer, request, http.StatusMethodNotAllowed, httpkit.CodeInvalidArgument, "invalid argument")
|
||
}
|
||
}
|
||
|
||
// createRoom 把创建房间 HTTP JSON 请求转换为 room-service CreateRoom 命令。
|
||
func (h *Handler) createRoom(writer http.ResponseWriter, request *http.Request) {
|
||
var body struct {
|
||
CommandID string `json:"command_id"`
|
||
SeatCount int32 `json:"seat_count"`
|
||
Mode string `json:"mode"`
|
||
RoomName string `json:"room_name"`
|
||
RoomAvatar string `json:"room_avatar"`
|
||
RoomDescription string `json:"room_description"`
|
||
}
|
||
|
||
if !httpkit.Decode(writer, request, &body) {
|
||
return
|
||
}
|
||
body.CommandID = strings.TrimSpace(body.CommandID)
|
||
body.Mode = strings.TrimSpace(body.Mode)
|
||
body.RoomName = strings.TrimSpace(body.RoomName)
|
||
body.RoomAvatar = strings.TrimSpace(body.RoomAvatar)
|
||
body.RoomDescription = strings.TrimSpace(body.RoomDescription)
|
||
if body.CommandID == "" || body.SeatCount < 0 || body.Mode == "" || body.RoomName == "" || 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]
|
||
}
|