diff --git a/docs/用户装扮Flutter对接文档.md b/docs/用户装扮Flutter对接文档.md index 1ebe3755..806159ca 100644 --- a/docs/用户装扮Flutter对接文档.md +++ b/docs/用户装扮Flutter对接文档.md @@ -62,6 +62,18 @@ GET /api/v1/users/me/appearance "entitlement_id": "ent-vehicle", "expires_at_ms": 1999999999999 }, + "mic_seat_animation": { + "resource_id": 8106, + "resource_code": "mic_seat_wave_gold", + "resource_type": "mic_seat_animation", + "name": "Gold Mic Wave", + "asset_url": "https://cdn.example/mic-seat.png", + "preview_url": "", + "animation_url": "https://cdn.example/mic-seat.svga", + "metadata_json": "{}", + "entitlement_id": "ent-mic-seat-wave", + "expires_at_ms": 1999999999999 + }, "equipped_badges": [], "badges": [ { @@ -94,7 +106,20 @@ GET /api/v1/users/me/appearance "honor_badges": [], "server_time_ms": 1778256000000 }, - "resources": [], + "resources": [ + { + "resource_id": 8106, + "resource_code": "mic_seat_wave_gold", + "resource_type": "mic_seat_animation", + "name": "Gold Mic Wave", + "asset_url": "https://cdn.example/mic-seat.png", + "preview_url": "", + "animation_url": "https://cdn.example/mic-seat.svga", + "metadata_json": "{}", + "entitlement_id": "ent-mic-seat-wave", + "expires_at_ms": 1999999999999 + } + ], "server_time_ms": 1778256000000 } ``` @@ -107,6 +132,7 @@ GET /api/v1/users/me/appearance | avatar_frame | 当前佩戴头像框,未佩戴时为 `{}` | | profile_card | 当前佩戴背景卡,未佩戴时为 `{}` | | vehicle | 当前佩戴入场座驾,未佩戴时为 `{}` | +| mic_seat_animation | 当前佩戴麦位声波,未佩戴时为 `{}` | | equipped_badges | wallet 当前佩戴的 badge 权益 | | badges | 房间、公屏等高频展示用徽章,取 `strip_badges` | | badge_profile | 完整徽章展示配置 | @@ -116,7 +142,7 @@ GET /api/v1/users/me/appearance 说明: - 过期佩戴不会返回。 -- 头像框、背景卡、入场座驾来自 wallet 当前佩戴。 +- 头像框、背景卡、入场座驾、麦位声波来自 wallet 当前佩戴。 - 徽章展示配置来自 activity。 - 客户端渲染动画优先使用 `animation_url`,没有动画时用 `asset_url` 或 `preview_url` 兜底。 @@ -345,6 +371,18 @@ GET /api/v1/users/room-display-profiles:batch?user_ids=42,43 "entitlement_id": "ent-vehicle", "expires_at_ms": 1999999999999 }, + "mic_seat_animation": { + "resource_id": 8106, + "resource_code": "mic_seat_wave_gold", + "resource_type": "mic_seat_animation", + "name": "Gold Mic Wave", + "asset_url": "https://cdn.example/mic-seat.png", + "preview_url": "", + "animation_url": "https://cdn.example/mic-seat.svga", + "metadata_json": "{}", + "entitlement_id": "ent-mic-seat-wave", + "expires_at_ms": 1999999999999 + }, "charm": 0 } ] @@ -356,6 +394,7 @@ GET /api/v1/users/room-display-profiles:batch?user_ids=42,43 - 房间、公屏、在线列表展示头像框用 `avatar_frame`。 - 房间资料卡展示用 `profile_card`,从 `metadata_json.profile_card_layout` 解析 `content_top` 和 `content_height`。 - 入场座驾展示用 `vehicle`。 +- 麦位声波展示用 `mic_seat_animation`。 - 徽章条展示用 `badges`。 - 过期佩戴不会返回。 diff --git a/services/gateway-service/internal/transport/http/response_test.go b/services/gateway-service/internal/transport/http/response_test.go index 80c542b9..1d802fe5 100644 --- a/services/gateway-service/internal/transport/http/response_test.go +++ b/services/gateway-service/internal/transport/http/response_test.go @@ -2440,6 +2440,17 @@ func TestJoinRoomReturnsInitialDataWithIMGroupRTCTokenAndProfiles(t *testing.T) GrantStrategy: "extend_expiry", Status: "active", } + micSeatResource := &walletv1.Resource{ + ResourceId: 8106, + ResourceCode: "mic_seat_wave_gold", + ResourceType: "mic_seat_animation", + Name: "Gold Mic Wave", + AssetUrl: "https://cdn.example/mic-seat.png", + AnimationUrl: "https://cdn.example/mic-seat.svga", + MetadataJson: `{"usage":"mic_seat"}`, + GrantStrategy: "set_active_flag", + Status: "active", + } shortBadgeResource := &walletv1.Resource{ ResourceId: 8105, ResourceCode: "badge_tile_gold", @@ -2471,6 +2482,14 @@ func TestJoinRoomReturnsInitialDataWithIMGroupRTCTokenAndProfiles(t *testing.T) Equipped: true, ExpiresAtMs: 1999999999999, }, + { + UserId: 42, + EntitlementId: "ent-mic-seat-wave", + ResourceId: 8106, + Resource: micSeatResource, + Equipped: true, + ExpiresAtMs: 1999999999999, + }, }, }}, }, @@ -2531,13 +2550,13 @@ func TestJoinRoomReturnsInitialDataWithIMGroupRTCTokenAndProfiles(t *testing.T) } hasRoomDisplayResourceQuery := false for _, req := range walletClient.batchEquippedRequests { - if strings.Join(req.GetResourceTypes(), ",") == "avatar_frame,profile_card,vehicle,badge" { + if strings.Join(req.GetResourceTypes(), ",") == "avatar_frame,profile_card,vehicle,badge,mic_seat_animation" { hasRoomDisplayResourceQuery = true break } } if !hasRoomDisplayResourceQuery { - t.Fatalf("join response must batch room wearable resources including profile_card: %+v", walletClient.batchEquippedRequests) + t.Fatalf("join response must batch room wearable resources including mic_seat_animation: %+v", walletClient.batchEquippedRequests) } profiles, profilesOK := data["profiles"].([]any) if !profilesOK || len(profiles) == 0 { @@ -2557,6 +2576,10 @@ func TestJoinRoomReturnsInitialDataWithIMGroupRTCTokenAndProfiles(t *testing.T) if !profileCardOK || profileCard["resource_id"] != float64(8104) || !strings.Contains(metadataJSON, `"content_top":143`) { t.Fatalf("join profile must include profile_card metadata_json: %+v", currentProfile) } + micSeatAnimation, micSeatAnimationOK := currentProfile["mic_seat_animation"].(map[string]any) + if !micSeatAnimationOK || micSeatAnimation["resource_id"] != float64(8106) || micSeatAnimation["animation_url"] != "https://cdn.example/mic-seat.svga" { + t.Fatalf("join profile must include mic_seat_animation resource: %+v", currentProfile) + } badges, badgesOK := currentProfile["badges"].([]any) if !badgesOK || len(badges) != 1 { t.Fatalf("join profile must include equipped badge list: %+v", currentProfile["badges"]) @@ -5928,6 +5951,17 @@ func TestAppearanceAggregatesEquippedResourcesAndBadges(t *testing.T) { GrantStrategy: "set_active_flag", Status: "active", } + micSeatResource := &walletv1.Resource{ + ResourceId: 8106, + ResourceCode: "mic_seat_wave_gold", + ResourceType: "mic_seat_animation", + Name: "Gold Mic Wave", + AssetUrl: "https://cdn.example/mic-seat.png", + AnimationUrl: "https://cdn.example/mic-seat.svga", + MetadataJson: `{"usage":"mic_seat"}`, + GrantStrategy: "set_active_flag", + Status: "active", + } walletClient := &fakeWalletClient{ batchEquippedResp: &walletv1.BatchGetUserEquippedResourcesResponse{ Users: []*walletv1.UserEquippedResources{{ @@ -5937,6 +5971,7 @@ func TestAppearanceAggregatesEquippedResourcesAndBadges(t *testing.T) { {UserId: 42, EntitlementId: "ent-vehicle", ResourceId: 8102, Resource: vehicleResource, Equipped: true, ExpiresAtMs: 1999999999999}, {UserId: 42, EntitlementId: "ent-profile-card", ResourceId: 8104, Resource: profileCardResource, Equipped: true, ExpiresAtMs: 1999999999999}, {UserId: 42, EntitlementId: "ent-badge", ResourceId: 8103, Resource: badgeResource, Equipped: true, ExpiresAtMs: 1999999999999}, + {UserId: 42, EntitlementId: "ent-mic-seat-wave", ResourceId: 8106, Resource: micSeatResource, Equipped: true, ExpiresAtMs: 1999999999999}, }, }}, }, @@ -5986,6 +6021,11 @@ func TestAppearanceAggregatesEquippedResourcesAndBadges(t *testing.T) { ResourceID int64 `json:"resource_id"` MetadataJSON string `json:"metadata_json"` } `json:"profile_card"` + MicSeatAnimation struct { + ResourceID int64 `json:"resource_id"` + AnimationURL string `json:"animation_url"` + EntitlementID string `json:"entitlement_id"` + } `json:"mic_seat_animation"` EquippedBadges []struct { ResourceID int64 `json:"resource_id"` } `json:"equipped_badges"` @@ -5995,6 +6035,10 @@ func TestAppearanceAggregatesEquippedResourcesAndBadges(t *testing.T) { AssetURL string `json:"asset_url"` } `json:"resource"` } `json:"badges"` + Resources []struct { + ResourceID int64 `json:"resource_id"` + ResourceType string `json:"resource_type"` + } `json:"resources"` } `json:"data"` } if err := json.NewDecoder(recorder.Body).Decode(&envelope); err != nil { @@ -6003,12 +6047,25 @@ func TestAppearanceAggregatesEquippedResourcesAndBadges(t *testing.T) { if walletClient.lastBatchEquipped == nil || walletClient.lastBatchEquipped.GetUserIds()[0] != 42 { t.Fatalf("appearance should batch query equipped resources: %+v", walletClient.lastBatchEquipped) } + if strings.Join(walletClient.lastBatchEquipped.GetResourceTypes(), ",") != "avatar_frame,profile_card,vehicle,badge,mic_seat_animation" { + t.Fatalf("appearance should query mic_seat_animation with wearable resources: %+v", walletClient.lastBatchEquipped.GetResourceTypes()) + } if achievementClient.lastBadges == nil || achievementClient.lastBadges.GetUserId() != 42 { t.Fatalf("appearance should query displayed badges: %+v", achievementClient.lastBadges) } - if envelope.Code != httpkit.CodeOK || envelope.Data.UserID != "42" || envelope.Data.AvatarFrame.AnimationURL != "https://cdn.example/frame.svga" || envelope.Data.Vehicle.ResourceID != 8102 || envelope.Data.ProfileCard.ResourceID != 8104 || !strings.Contains(envelope.Data.ProfileCard.MetadataJSON, `"content_top":117`) || len(envelope.Data.EquippedBadges) != 1 || len(envelope.Data.Badges) != 1 || envelope.Data.Badges[0].Resource.AssetURL != "https://cdn.example/badge.png" { + if envelope.Code != httpkit.CodeOK || envelope.Data.UserID != "42" || envelope.Data.AvatarFrame.AnimationURL != "https://cdn.example/frame.svga" || envelope.Data.Vehicle.ResourceID != 8102 || envelope.Data.ProfileCard.ResourceID != 8104 || !strings.Contains(envelope.Data.ProfileCard.MetadataJSON, `"content_top":117`) || envelope.Data.MicSeatAnimation.ResourceID != 8106 || envelope.Data.MicSeatAnimation.AnimationURL != "https://cdn.example/mic-seat.svga" || envelope.Data.MicSeatAnimation.EntitlementID != "ent-mic-seat-wave" || len(envelope.Data.EquippedBadges) != 1 || len(envelope.Data.Badges) != 1 || envelope.Data.Badges[0].Resource.AssetURL != "https://cdn.example/badge.png" { t.Fatalf("appearance response mismatch: %+v", envelope) } + hasMicSeatResource := false + for _, resource := range envelope.Data.Resources { + if resource.ResourceID == 8106 && resource.ResourceType == "mic_seat_animation" { + hasMicSeatResource = true + break + } + } + if !hasMicSeatResource { + t.Fatalf("appearance resources must include equipped mic_seat_animation: %+v", envelope.Data.Resources) + } } func TestListEmojiPacksFiltersRegionAndReturnsAssets(t *testing.T) { diff --git a/services/gateway-service/internal/transport/http/roomapi/room_handler.go b/services/gateway-service/internal/transport/http/roomapi/room_handler.go index e36ec81c..3806b7b0 100644 --- a/services/gateway-service/internal/transport/http/roomapi/room_handler.go +++ b/services/gateway-service/internal/transport/http/roomapi/room_handler.go @@ -24,6 +24,13 @@ import ( const ( defaultRoomFeedRelationPageSize = 100 maxRoomFeedRelationScanCount = 500 + + // 房间展示资料只读取客户端能在首屏、公屏或资料卡直接渲染的当前佩戴资源。 + roomAppearanceResourceAvatarFrame = "avatar_frame" + roomAppearanceResourceProfileCard = "profile_card" + roomAppearanceResourceVehicle = "vehicle" + roomAppearanceResourceBadge = "badge" + roomAppearanceResourceMicSeatAnimation = "mic_seat_animation" ) type flexibleInt64 int64 @@ -1649,6 +1656,7 @@ func (h *Handler) roomDisplayProfileMap(request *http.Request, userIDs []int64) AvatarFrame: appearance.AvatarFrame, ProfileCard: appearance.ProfileCard, Vehicle: appearance.Vehicle, + MicSeatAnimation: appearance.MicSeatAnimation, Charm: 0, } // 显式补空对象/空数组,避免客户端在房间资料中同时处理 null 和空值。 @@ -1677,6 +1685,11 @@ func (h *Handler) roomDisplayProfileMap(request *http.Request, userIDs []int64) profile.Vehicle = map[string]any{} profiles[userID] = profile } + if profiles[userID].MicSeatAnimation == nil { + profile := profiles[userID] + profile.MicSeatAnimation = map[string]any{} + profiles[userID] = profile + } if profiles[userID].ProfileCard == nil { profile := profiles[userID] profile.ProfileCard = map[string]any{} @@ -1687,10 +1700,11 @@ func (h *Handler) roomDisplayProfileMap(request *http.Request, userIDs []int64) } type roomAppearanceResources struct { - AvatarFrame map[string]any - ProfileCard map[string]any - Vehicle map[string]any - Badges []map[string]any + AvatarFrame map[string]any + ProfileCard map[string]any + Vehicle map[string]any + MicSeatAnimation map[string]any + Badges []map[string]any } func (h *Handler) roomAppearanceResourceMap(request *http.Request, userIDs []int64) map[int64]roomAppearanceResources { @@ -1703,7 +1717,7 @@ func (h *Handler) roomAppearanceResourceMap(request *http.Request, userIDs []int RequestId: httpkit.RequestIDFromContext(request.Context()), AppCode: appcode.FromContext(request.Context()), UserIds: userIDs, - ResourceTypes: []string{"avatar_frame", "profile_card", "vehicle", "badge"}, + ResourceTypes: []string{roomAppearanceResourceAvatarFrame, roomAppearanceResourceProfileCard, roomAppearanceResourceVehicle, roomAppearanceResourceBadge, roomAppearanceResourceMicSeatAnimation}, }) if err != nil { // 装扮是展示增强,失败不影响房间主数据返回。 @@ -1718,14 +1732,16 @@ func (h *Handler) roomAppearanceResourceMap(request *http.Request, userIDs []int // 同一资源类型只保留当前佩戴的一个权益;wallet equipment 主键保证唯一性。 resource := roomAppearanceResourceFromEntitlement(item) switch item.GetResource().GetResourceType() { - case "avatar_frame": + case roomAppearanceResourceAvatarFrame: current.AvatarFrame = resource - case "profile_card": + case roomAppearanceResourceProfileCard: current.ProfileCard = resource - case "vehicle": + case roomAppearanceResourceVehicle: current.Vehicle = resource - case "badge": + case roomAppearanceResourceBadge: current.Badges = append(current.Badges, roomBadgeItemFromEquippedResource(resource)) + case roomAppearanceResourceMicSeatAnimation: + current.MicSeatAnimation = resource } } result[user.GetUserId()] = current diff --git a/services/gateway-service/internal/transport/http/roomapi/room_view.go b/services/gateway-service/internal/transport/http/roomapi/room_view.go index 6a156379..82c3525d 100644 --- a/services/gateway-service/internal/transport/http/roomapi/room_view.go +++ b/services/gateway-service/internal/transport/http/roomapi/room_view.go @@ -172,6 +172,7 @@ type roomDisplayProfileData struct { AvatarFrame map[string]any `json:"avatar_frame"` ProfileCard map[string]any `json:"profile_card"` Vehicle map[string]any `json:"vehicle"` + MicSeatAnimation map[string]any `json:"mic_seat_animation"` Charm int64 `json:"charm"` } diff --git a/services/gateway-service/internal/transport/http/userapi/appearance_handler.go b/services/gateway-service/internal/transport/http/userapi/appearance_handler.go index 385acc95..45c96322 100644 --- a/services/gateway-service/internal/transport/http/userapi/appearance_handler.go +++ b/services/gateway-service/internal/transport/http/userapi/appearance_handler.go @@ -17,26 +17,28 @@ import ( const ( // /appearance 只暴露客户端能直接佩戴或展示的装扮维度;类型值保持和 wallet resource_type 一致。 - appearanceResourceAvatarFrame = "avatar_frame" - appearanceResourceProfileCard = "profile_card" - appearanceResourceVehicle = "vehicle" - appearanceResourceBadge = "badge" + appearanceResourceAvatarFrame = "avatar_frame" + appearanceResourceProfileCard = "profile_card" + appearanceResourceVehicle = "vehicle" + appearanceResourceBadge = "badge" + appearanceResourceMicSeatAnimation = "mic_seat_animation" ) var errAppearanceUpstream = errors.New("upstream service error") type appearanceData struct { - UserID string `json:"user_id"` - AvatarFrame map[string]any `json:"avatar_frame"` - ProfileCard map[string]any `json:"profile_card"` - Vehicle map[string]any `json:"vehicle"` - Level map[string]any `json:"level"` - LevelBadges map[string]any `json:"level_badges"` - EquippedBadges []map[string]any `json:"equipped_badges"` - Badges []map[string]any `json:"badges"` - BadgeProfile appearanceBadgeData `json:"badge_profile"` - Resources []map[string]any `json:"resources"` - ServerTimeMS int64 `json:"server_time_ms"` + UserID string `json:"user_id"` + AvatarFrame map[string]any `json:"avatar_frame"` + ProfileCard map[string]any `json:"profile_card"` + Vehicle map[string]any `json:"vehicle"` + MicSeatAnimation map[string]any `json:"mic_seat_animation"` + Level map[string]any `json:"level"` + LevelBadges map[string]any `json:"level_badges"` + EquippedBadges []map[string]any `json:"equipped_badges"` + Badges []map[string]any `json:"badges"` + BadgeProfile appearanceBadgeData `json:"badge_profile"` + Resources []map[string]any `json:"resources"` + ServerTimeMS int64 `json:"server_time_ms"` } type appearanceBadgeData struct { @@ -80,15 +82,16 @@ func (h *Handler) writeAppearance(writer http.ResponseWriter, request *http.Requ func (h *Handler) appearanceForUser(request *http.Request, userID int64) (appearanceData, error) { // 先初始化为空对象/数组,保证客户端不需要处理 null 和字段缺失两套形态。 data := appearanceData{ - UserID: strconv.FormatInt(userID, 10), - AvatarFrame: map[string]any{}, - ProfileCard: map[string]any{}, - Vehicle: map[string]any{}, - Level: defaultAppearanceLevelDisplay(), - LevelBadges: map[string]any{}, - EquippedBadges: []map[string]any{}, - Badges: []map[string]any{}, - Resources: []map[string]any{}, + UserID: strconv.FormatInt(userID, 10), + AvatarFrame: map[string]any{}, + ProfileCard: map[string]any{}, + Vehicle: map[string]any{}, + MicSeatAnimation: map[string]any{}, + Level: defaultAppearanceLevelDisplay(), + LevelBadges: map[string]any{}, + EquippedBadges: []map[string]any{}, + Badges: []map[string]any{}, + Resources: []map[string]any{}, } // 当前佩戴只走 BatchGetUserEquippedResources: // 1. wallet 内部会过滤过期佩戴; @@ -97,7 +100,7 @@ func (h *Handler) appearanceForUser(request *http.Request, userID int64) (appear RequestId: httpkit.RequestIDFromContext(request.Context()), AppCode: appcode.FromContext(request.Context()), UserIds: []int64{userID}, - ResourceTypes: []string{appearanceResourceAvatarFrame, appearanceResourceProfileCard, appearanceResourceVehicle, appearanceResourceBadge}, + ResourceTypes: []string{appearanceResourceAvatarFrame, appearanceResourceProfileCard, appearanceResourceVehicle, appearanceResourceBadge, appearanceResourceMicSeatAnimation}, }) if err != nil { return data, err @@ -120,6 +123,8 @@ func (h *Handler) appearanceForUser(request *http.Request, userID int64) (appear data.Vehicle = resource case appearanceResourceBadge: data.EquippedBadges = append(data.EquippedBadges, resource) + case appearanceResourceMicSeatAnimation: + data.MicSeatAnimation = resource } } }