From 5e124361f88dd619dc411102b330306472de58f6 Mon Sep 17 00:00:00 2001 From: zhx Date: Mon, 20 Jul 2026 21:37:27 +0800 Subject: [PATCH] =?UTF-8?q?vip=E7=9B=B8=E5=85=B3=E6=8E=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/proto/events/room/v1/events.pb.go | 38 ++- api/proto/events/room/v1/events.proto | 4 + api/proto/room/v1/room.pb.go | 68 +++- api/proto/room/v1/room.proto | 8 + .../Fami_VIP五阶段Flutter接口对接.md | 5 +- docs/flutter对接/幸运礼物Flutter对接.md | 7 +- .../房间列表房主VIP等级Flutter对接.md | 310 ++++++++++++++++++ docs/flutter对接/礼物TabFlutter对接.md | 20 +- docs/openapi/gateway.swagger.yaml | 94 ++++++ docs/openapi/room.swagger.yaml | 3 + docs/接口清单.md | 5 +- .../transport/http/httproutes/router.go | 2 + .../internal/transport/http/response_test.go | 71 +++- .../transport/http/roomapi/handler.go | 1 + .../transport/http/roomapi/room_handler.go | 85 ++++- .../transport/http/roomapi/room_view.go | 12 + .../http/roomapi/room_vip_handler.go | 53 +++ .../internal/integration/tencent_im.go | 6 + .../internal/integration/tencent_im_test.go | 35 +- .../internal/room/command/command.go | 2 + .../internal/room/service/gift_batch.go | 2 + .../internal/room/service/gift_events.go | 1 + .../internal/room/service/gift_events_test.go | 6 +- .../internal/room/service/gift_profile.go | 6 + .../internal/room/service/list.go | 4 +- .../room/service/list_cache_service_test.go | 4 + .../internal/room/service/recovery_test.go | 17 + .../room-service/internal/room/state/state.go | 4 +- 28 files changed, 830 insertions(+), 43 deletions(-) create mode 100644 docs/flutter对接/房间列表房主VIP等级Flutter对接.md diff --git a/api/proto/events/room/v1/events.pb.go b/api/proto/events/room/v1/events.pb.go index 115974ab..52ac46c3 100644 --- a/api/proto/events/room/v1/events.pb.go +++ b/api/proto/events/room/v1/events.pb.go @@ -1720,9 +1720,11 @@ type RoomGiftSent struct { // display_mode=batch 表示这条逐目标事实只给统计、activity、CP 和礼物墙消费;房间展示由同命令的 RoomGiftBatchSent 承担。 DisplayMode string `protobuf:"bytes,31,opt,name=display_mode,json=displayMode,proto3" json:"display_mode,omitempty"` // room_locked 固化送礼命令在 Room Cell 内执行时的锁房状态,跨房飘屏点击不能依赖首页缓存猜测。 - RoomLocked bool `protobuf:"varint,32,opt,name=room_locked,json=roomLocked,proto3" json:"room_locked,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + RoomLocked bool `protobuf:"varint,32,opt,name=room_locked,json=roomLocked,proto3" json:"room_locked,omitempty"` + // sender_vip_level 是送礼入口固化的发送者 effective VIP 等级;0 表示当前无有效 VIP 或展示查询已降级。 + SenderVipLevel int32 `protobuf:"varint,33,opt,name=sender_vip_level,json=senderVipLevel,proto3" json:"sender_vip_level,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *RoomGiftSent) Reset() { @@ -1979,6 +1981,13 @@ func (x *RoomGiftSent) GetRoomLocked() bool { return false } +func (x *RoomGiftSent) GetSenderVipLevel() int32 { + if x != nil { + return x.SenderVipLevel + } + return 0 +} + // RoomGiftBatchLuckyResult 是批量送礼展示事件内的逐目标幸运礼物结果快照。 // 它只进入房间 IM 展示,不作为 activity 中奖事实,也不携带送礼人余额。 type RoomGiftBatchLuckyResult struct { @@ -2297,8 +2306,10 @@ type RoomGiftBatchSent struct { Targets []*RoomGiftBatchTarget `protobuf:"bytes,20,rep,name=targets,proto3" json:"targets,omitempty"` CommandId string `protobuf:"bytes,21,opt,name=command_id,json=commandId,proto3" json:"command_id,omitempty"` VisibleRegionId int64 `protobuf:"varint,22,opt,name=visible_region_id,json=visibleRegionId,proto3" json:"visible_region_id,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + // sender_vip_level 与同命令的逐目标 RoomGiftSent 共用一份发送者展示快照。 + SenderVipLevel int32 `protobuf:"varint,23,opt,name=sender_vip_level,json=senderVipLevel,proto3" json:"sender_vip_level,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *RoomGiftBatchSent) Reset() { @@ -2485,6 +2496,13 @@ func (x *RoomGiftBatchSent) GetVisibleRegionId() int64 { return 0 } +func (x *RoomGiftBatchSent) GetSenderVipLevel() int32 { + if x != nil { + return x.SenderVipLevel + } + return 0 +} + // RoomLuckyGiftDrawn 是 room-service 从 lucky-gift-service owner 事实派生的真人中奖展示事件。 // 它只承载腾讯云 IM 房间表现,不是 Room Cell 核心状态,不能推进 room_version、榜单或房间快照。 type RoomLuckyGiftDrawn struct { @@ -3738,7 +3756,7 @@ const file_proto_events_room_v1_events_proto_rawDesc = "" + "\x0etarget_user_id\x18\x02 \x01(\x03R\ftargetUserId\"\\\n" + "\x10RoomUserUnbanned\x12\"\n" + "\ractor_user_id\x18\x01 \x01(\x03R\vactorUserId\x12$\n" + - "\x0etarget_user_id\x18\x02 \x01(\x03R\ftargetUserId\"\x92\n" + + "\x0etarget_user_id\x18\x02 \x01(\x03R\ftargetUserId\"\xbc\n" + "\n" + "\fRoomGiftSent\x12$\n" + "\x0esender_user_id\x18\x01 \x01(\x03R\fsenderUserId\x12$\n" + @@ -3780,7 +3798,8 @@ const file_proto_events_room_v1_events_proto_rawDesc = "" + "\x1freceiver_pretty_display_user_id\x18\x1e \x01(\tR\x1breceiverPrettyDisplayUserId\x12!\n" + "\fdisplay_mode\x18\x1f \x01(\tR\vdisplayMode\x12\x1f\n" + "\vroom_locked\x18 \x01(\bR\n" + - "roomLocked\"\xdc\x04\n" + + "roomLocked\x12(\n" + + "\x10sender_vip_level\x18! \x01(\x05R\x0esenderVipLevel\"\xdc\x04\n" + "\x18RoomGiftBatchLuckyResult\x12\x18\n" + "\aenabled\x18\x01 \x01(\bR\aenabled\x12\x17\n" + "\adraw_id\x18\x02 \x01(\tR\x06drawId\x12\x1d\n" + @@ -3816,7 +3835,7 @@ const file_proto_events_room_v1_events_proto_rawDesc = "" + "command_id\x18\n" + " \x01(\tR\tcommandId\x12M\n" + "\n" + - "lucky_gift\x18\v \x01(\v2..hyapp.events.room.v1.RoomGiftBatchLuckyResultR\tluckyGift\"\x89\a\n" + + "lucky_gift\x18\v \x01(\v2..hyapp.events.room.v1.RoomGiftBatchLuckyResultR\tluckyGift\"\xb3\a\n" + "\x11RoomGiftBatchSent\x12$\n" + "\x0esender_user_id\x18\x01 \x01(\x03R\fsenderUserId\x12\x1f\n" + "\vsender_name\x18\x02 \x01(\tR\n" + @@ -3843,7 +3862,8 @@ const file_proto_events_room_v1_events_proto_rawDesc = "" + "\atargets\x18\x14 \x03(\v2).hyapp.events.room.v1.RoomGiftBatchTargetR\atargets\x12\x1d\n" + "\n" + "command_id\x18\x15 \x01(\tR\tcommandId\x12*\n" + - "\x11visible_region_id\x18\x16 \x01(\x03R\x0fvisibleRegionId\"\xb7\b\n" + + "\x11visible_region_id\x18\x16 \x01(\x03R\x0fvisibleRegionId\x12(\n" + + "\x10sender_vip_level\x18\x17 \x01(\x05R\x0esenderVipLevel\"\xb7\b\n" + "\x12RoomLuckyGiftDrawn\x12\x17\n" + "\adraw_id\x18\x01 \x01(\tR\x06drawId\x12\x1d\n" + "\n" + diff --git a/api/proto/events/room/v1/events.proto b/api/proto/events/room/v1/events.proto index 45e6f50f..4b2fda8a 100644 --- a/api/proto/events/room/v1/events.proto +++ b/api/proto/events/room/v1/events.proto @@ -250,6 +250,8 @@ message RoomGiftSent { string display_mode = 31; // room_locked 固化送礼命令在 Room Cell 内执行时的锁房状态,跨房飘屏点击不能依赖首页缓存猜测。 bool room_locked = 32; + // sender_vip_level 是送礼入口固化的发送者 effective VIP 等级;0 表示当前无有效 VIP 或展示查询已降级。 + int32 sender_vip_level = 33; } // RoomGiftBatchLuckyResult 是批量送礼展示事件内的逐目标幸运礼物结果快照。 @@ -314,6 +316,8 @@ message RoomGiftBatchSent { repeated RoomGiftBatchTarget targets = 20; string command_id = 21; int64 visible_region_id = 22; + // sender_vip_level 与同命令的逐目标 RoomGiftSent 共用一份发送者展示快照。 + int32 sender_vip_level = 23; } // RoomLuckyGiftDrawn 是 room-service 从 lucky-gift-service owner 事实派生的真人中奖展示事件。 diff --git a/api/proto/room/v1/room.pb.go b/api/proto/room/v1/room.pb.go index 821208e1..d78a16a3 100644 --- a/api/proto/room/v1/room.pb.go +++ b/api/proto/room/v1/room.pb.go @@ -4590,8 +4590,10 @@ type RoomSnapshot struct { RoomNameStyle *RoomDecorationResource `protobuf:"bytes,24,opt,name=room_name_style,json=roomNameStyle,proto3" json:"room_name_style,omitempty"` // active_background 固化当前背景的真实媒体属性;room_ext.background_url 仅保留旧客户端兼容。 ActiveBackground *RoomBackgroundImage `protobuf:"bytes,25,opt,name=active_background,json=activeBackground,proto3" json:"active_background,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + // room_border_url 是当前有效 room_border.asset_url 的扁平兼容字段;空字符串表示没有有效房间边框。 + RoomBorderUrl string `protobuf:"bytes,26,opt,name=room_border_url,json=roomBorderUrl,proto3" json:"room_border_url,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *RoomSnapshot) Reset() { @@ -4792,6 +4794,13 @@ func (x *RoomSnapshot) GetActiveBackground() *RoomBackgroundImage { return nil } +func (x *RoomSnapshot) GetRoomBorderUrl() string { + if x != nil { + return x.RoomBorderUrl + } + return "" +} + // RoomMedia 是房间背景和房间图片消息共同使用的可信媒体快照。 // URL 只能由 gateway 的受信对象存储上传入口生成;room-service 会再次校验 object_key 归属和媒体边界。 type RoomMedia struct { @@ -9958,8 +9967,10 @@ type SendGiftDisplayProfile struct { Avatar string `protobuf:"bytes,3,opt,name=avatar,proto3" json:"avatar,omitempty"` DisplayUserId string `protobuf:"bytes,4,opt,name=display_user_id,json=displayUserId,proto3" json:"display_user_id,omitempty"` PrettyDisplayUserId string `protobuf:"bytes,5,opt,name=pretty_display_user_id,json=prettyDisplayUserId,proto3" json:"pretty_display_user_id,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + // effective_vip_level 是送礼入口查询到的当前生效 VIP 等级;0 表示当前无有效 VIP 或展示查询已降级。 + EffectiveVipLevel int32 `protobuf:"varint,6,opt,name=effective_vip_level,json=effectiveVipLevel,proto3" json:"effective_vip_level,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *SendGiftDisplayProfile) Reset() { @@ -10027,6 +10038,13 @@ func (x *SendGiftDisplayProfile) GetPrettyDisplayUserId() string { return "" } +func (x *SendGiftDisplayProfile) GetEffectiveVipLevel() int32 { + if x != nil { + return x.EffectiveVipLevel + } + return 0 +} + // SendGiftBatchTarget 是批量送礼展示响应中单个目标的结算和展示快照。 // 它只服务发送方本地即时展示,不替代逐目标 RoomGiftSent 业务事实。 type SendGiftBatchTarget struct { @@ -10179,8 +10197,10 @@ type SendGiftBatchDisplay struct { TargetUserIds []int64 `protobuf:"varint,20,rep,packed,name=target_user_ids,json=targetUserIds,proto3" json:"target_user_ids,omitempty"` Targets []*SendGiftBatchTarget `protobuf:"bytes,21,rep,name=targets,proto3" json:"targets,omitempty"` CommandId string `protobuf:"bytes,22,opt,name=command_id,json=commandId,proto3" json:"command_id,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + // sender_vip_level 与 RoomGiftBatchSent IM 使用同一份入站快照,避免发送方本地展示和房间广播不一致。 + SenderVipLevel int32 `protobuf:"varint,23,opt,name=sender_vip_level,json=senderVipLevel,proto3" json:"sender_vip_level,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *SendGiftBatchDisplay) Reset() { @@ -10367,6 +10387,13 @@ func (x *SendGiftBatchDisplay) GetCommandId() string { return "" } +func (x *SendGiftBatchDisplay) GetSenderVipLevel() int32 { + if x != nil { + return x.SenderVipLevel + } + return 0 +} + // SendGiftRequest 是首版房间内最重要的变现命令。 type SendGiftRequest struct { state protoimpl.MessageState `protogen:"open.v1"` @@ -11558,6 +11585,8 @@ type RoomListItem struct { // owner_vip_level 是 wallet effective VIP 的异步列表投影;0 表示房主当前没有有效 VIP。 // room-service 在创建/房主进房和 wallet outbox 变化时刷新,列表查询本身不回查 wallet。 OwnerVipLevel int32 `protobuf:"varint,19,opt,name=owner_vip_level,json=ownerVipLevel,proto3" json:"owner_vip_level,omitempty"` + // room_border_url 直接从同一列表投影中的 room_border.asset_url 派生,不增加列表查询或 wallet 回查。 + RoomBorderUrl string `protobuf:"bytes,20,opt,name=room_border_url,json=roomBorderUrl,proto3" json:"room_border_url,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -11718,6 +11747,13 @@ func (x *RoomListItem) GetOwnerVipLevel() int32 { return 0 } +func (x *RoomListItem) GetRoomBorderUrl() string { + if x != nil { + return x.RoomBorderUrl + } + return "" +} + // ListRoomsResponse 返回一页房间卡片和下一页不透明 cursor。 type ListRoomsResponse struct { state protoimpl.MessageState `protogen:"open.v1"` @@ -14291,7 +14327,7 @@ const file_proto_room_v1_room_proto_rawDesc = "" + "\auser_id\x18\x01 \x01(\x03R\x06userId\x12\"\n" + "\ractor_user_id\x18\x02 \x01(\x03R\vactorUserId\x12\"\n" + "\rcreated_at_ms\x18\x03 \x01(\x03R\vcreatedAtMs\x12\"\n" + - "\rexpires_at_ms\x18\x04 \x01(\x03R\vexpiresAtMs\"\xe0\b\n" + + "\rexpires_at_ms\x18\x04 \x01(\x03R\vexpiresAtMs\"\x88\t\n" + "\fRoomSnapshot\x12\x17\n" + "\aroom_id\x18\x01 \x01(\tR\x06roomId\x12\"\n" + "\rowner_user_id\x18\x02 \x01(\x03R\vownerUserId\x12\x12\n" + @@ -14320,7 +14356,8 @@ const file_proto_room_v1_room_proto_rawDesc = "" + "\vroom_border\x18\x17 \x01(\v2%.hyapp.room.v1.RoomDecorationResourceR\n" + "roomBorder\x12M\n" + "\x0froom_name_style\x18\x18 \x01(\v2%.hyapp.room.v1.RoomDecorationResourceR\rroomNameStyle\x12O\n" + - "\x11active_background\x18\x19 \x01(\v2\".hyapp.room.v1.RoomBackgroundImageR\x10activeBackground\x1a:\n" + + "\x11active_background\x18\x19 \x01(\v2\".hyapp.room.v1.RoomBackgroundImageR\x10activeBackground\x12&\n" + + "\x0froom_border_url\x18\x1a \x01(\tR\rroomBorderUrl\x1a:\n" + "\fRoomExtEntry\x12\x10\n" + "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\"\x96\x03\n" + @@ -14754,13 +14791,14 @@ const file_proto_room_v1_room_proto_rawDesc = "" + "\x0etarget_user_id\x18\x01 \x01(\x03R\ftargetUserId\x12$\n" + "\x0etarget_is_host\x18\x02 \x01(\bR\ftargetIsHost\x121\n" + "\x15target_host_region_id\x18\x03 \x01(\x03R\x12targetHostRegionId\x12<\n" + - "\x1btarget_agency_owner_user_id\x18\x04 \x01(\x03R\x17targetAgencyOwnerUserId\"\xc2\x01\n" + + "\x1btarget_agency_owner_user_id\x18\x04 \x01(\x03R\x17targetAgencyOwnerUserId\"\xf2\x01\n" + "\x16SendGiftDisplayProfile\x12\x17\n" + "\auser_id\x18\x01 \x01(\x03R\x06userId\x12\x1a\n" + "\busername\x18\x02 \x01(\tR\busername\x12\x16\n" + "\x06avatar\x18\x03 \x01(\tR\x06avatar\x12&\n" + "\x0fdisplay_user_id\x18\x04 \x01(\tR\rdisplayUserId\x123\n" + - "\x16pretty_display_user_id\x18\x05 \x01(\tR\x13prettyDisplayUserId\"\x8a\x04\n" + + "\x16pretty_display_user_id\x18\x05 \x01(\tR\x13prettyDisplayUserId\x12.\n" + + "\x13effective_vip_level\x18\x06 \x01(\x05R\x11effectiveVipLevel\"\x8a\x04\n" + "\x13SendGiftBatchTarget\x12$\n" + "\x0etarget_user_id\x18\x01 \x01(\x03R\ftargetUserId\x12+\n" + "\x11receiver_nickname\x18\x02 \x01(\tR\x10receiverNickname\x12'\n" + @@ -14777,7 +14815,7 @@ const file_proto_room_v1_room_proto_rawDesc = "" + "command_id\x18\n" + " \x01(\tR\tcommandId\x12A\n" + "\n" + - "lucky_gift\x18\v \x01(\v2\".hyapp.room.v1.LuckyGiftDrawResultR\tluckyGift\"\xf4\x06\n" + + "lucky_gift\x18\v \x01(\v2\".hyapp.room.v1.LuckyGiftDrawResultR\tluckyGift\"\x9e\a\n" + "\x14SendGiftBatchDisplay\x12\x19\n" + "\bevent_id\x18\x01 \x01(\tR\aeventId\x12$\n" + "\x0esender_user_id\x18\x02 \x01(\x03R\fsenderUserId\x12\x1f\n" + @@ -14804,7 +14842,8 @@ const file_proto_room_v1_room_proto_rawDesc = "" + "\x0ftarget_user_ids\x18\x14 \x03(\x03R\rtargetUserIds\x12<\n" + "\atargets\x18\x15 \x03(\v2\".hyapp.room.v1.SendGiftBatchTargetR\atargets\x12\x1d\n" + "\n" + - "command_id\x18\x16 \x01(\tR\tcommandId\"\xe0\a\n" + + "command_id\x18\x16 \x01(\tR\tcommandId\x12(\n" + + "\x10sender_vip_level\x18\x17 \x01(\x05R\x0esenderVipLevel\"\xe0\a\n" + "\x0fSendGiftRequest\x12.\n" + "\x04meta\x18\x01 \x01(\v2\x1a.hyapp.room.v1.RequestMetaR\x04meta\x12$\n" + "\x0etarget_user_id\x18\x02 \x01(\x03R\ftargetUserId\x12\x17\n" + @@ -14909,7 +14948,7 @@ const file_proto_room_v1_room_proto_rawDesc = "" + "\tpage_size\x18\x04 \x01(\x05R\bpageSize\"c\n" + "\x13RoomFeedRelatedUser\x12\x17\n" + "\auser_id\x18\x01 \x01(\x03R\x06userId\x123\n" + - "\x16relation_updated_at_ms\x18\x02 \x01(\x03R\x13relationUpdatedAtMs\"\x95\x05\n" + + "\x16relation_updated_at_ms\x18\x02 \x01(\x03R\x13relationUpdatedAtMs\"\xbd\x05\n" + "\fRoomListItem\x12\x17\n" + "\aroom_id\x18\x01 \x01(\tR\x06roomId\x12\"\n" + "\rowner_user_id\x18\x02 \x01(\x03R\vownerUserId\x12\x14\n" + @@ -14931,7 +14970,8 @@ const file_proto_room_v1_room_proto_rawDesc = "" + "\vroom_border\x18\x11 \x01(\v2%.hyapp.room.v1.RoomDecorationResourceR\n" + "roomBorder\x12M\n" + "\x0froom_name_style\x18\x12 \x01(\v2%.hyapp.room.v1.RoomDecorationResourceR\rroomNameStyle\x12&\n" + - "\x0fowner_vip_level\x18\x13 \x01(\x05R\rownerVipLevel\"g\n" + + "\x0fowner_vip_level\x18\x13 \x01(\x05R\rownerVipLevel\x12&\n" + + "\x0froom_border_url\x18\x14 \x01(\tR\rroomBorderUrl\"g\n" + "\x11ListRoomsResponse\x121\n" + "\x05rooms\x18\x01 \x03(\v2\x1b.hyapp.room.v1.RoomListItemR\x05rooms\x12\x1f\n" + "\vnext_cursor\x18\x02 \x01(\tR\n" + diff --git a/api/proto/room/v1/room.proto b/api/proto/room/v1/room.proto index 4b6b5dd7..e46b8a38 100644 --- a/api/proto/room/v1/room.proto +++ b/api/proto/room/v1/room.proto @@ -557,6 +557,8 @@ message RoomSnapshot { RoomDecorationResource room_name_style = 24; // active_background 固化当前背景的真实媒体属性;room_ext.background_url 仅保留旧客户端兼容。 RoomBackgroundImage active_background = 25; + // room_border_url 是当前有效 room_border.asset_url 的扁平兼容字段;空字符串表示没有有效房间边框。 + string room_border_url = 26; } // RoomMedia 是房间背景和房间图片消息共同使用的可信媒体快照。 @@ -1192,6 +1194,8 @@ message SendGiftDisplayProfile { string avatar = 3; string display_user_id = 4; string pretty_display_user_id = 5; + // effective_vip_level 是送礼入口查询到的当前生效 VIP 等级;0 表示当前无有效 VIP 或展示查询已降级。 + int32 effective_vip_level = 6; } // SendGiftBatchTarget 是批量送礼展示响应中单个目标的结算和展示快照。 @@ -1235,6 +1239,8 @@ message SendGiftBatchDisplay { repeated int64 target_user_ids = 20; repeated SendGiftBatchTarget targets = 21; string command_id = 22; + // sender_vip_level 与 RoomGiftBatchSent IM 使用同一份入站快照,避免发送方本地展示和房间广播不一致。 + int32 sender_vip_level = 23; } // SendGiftRequest 是首版房间内最重要的变现命令。 @@ -1424,6 +1430,8 @@ message RoomListItem { // owner_vip_level 是 wallet effective VIP 的异步列表投影;0 表示房主当前没有有效 VIP。 // room-service 在创建/房主进房和 wallet outbox 变化时刷新,列表查询本身不回查 wallet。 int32 owner_vip_level = 19; + // room_border_url 直接从同一列表投影中的 room_border.asset_url 派生,不增加列表查询或 wallet 回查。 + string room_border_url = 20; } // ListRoomsResponse 返回一页房间卡片和下一页不透明 cursor。 diff --git a/docs/flutter对接/Fami_VIP五阶段Flutter接口对接.md b/docs/flutter对接/Fami_VIP五阶段Flutter接口对接.md index f9dabfad..e23c2b29 100644 --- a/docs/flutter对接/Fami_VIP五阶段Flutter接口对接.md +++ b/docs/flutter对接/Fami_VIP五阶段Flutter接口对接.md @@ -91,6 +91,7 @@ | 保存房间背景素材 | `POST` | `/api/v1/rooms/backgrounds/save` | | 查询房间背景素材 | `GET` | `/api/v1/rooms/{room_id}/backgrounds` | | 切换当前背景 | `POST` | `/api/v1/rooms/backgrounds/set` | +| 保存当前房间边框 | `POST` | `/api/v1/rooms/{room_id}/border/save` | | 应用房间装扮 | `POST` | `/api/v1/rooms/{room_id}/decorations/apply` | | 记录主页访问 | `POST` | `/api/v1/users/{user_id}/visit` | | 查询访问我的用户 | `GET` | `/api/v1/users/me/visitors?page=1&page_size=20` | @@ -839,6 +840,8 @@ GET /api/v1/users/me/resources?resource_type=room_border GET /api/v1/users/me/resources?resource_type=room_name_style ``` +只保存房间边框时优先使用专用入口 `POST /api/v1/rooms/{room_id}/border/save`,请求只传 `command_id/resource_id/entitlement_id`;服务端返回字符串 `room_border_url`。通用 `/decorations/apply` 继续用于彩色房名及兼容调用。 + 然后由房主应用到具体房间: ```http @@ -921,7 +924,7 @@ Content-Type: application/json 彩色房名 `text_colors` 最多 8 个,颜色只接受 `#RRGGBB` 或 `#RRGGBBAA`;`stops` 若提供,数量必须与颜色一致、范围 `[0,1]` 且升序。Flutter 直接渲染响应字段,不再次解析 `metadata_json` 作为权限事实。 -房间列表、feed、详情和命令响应会直接返回最终 `room_border`、`room_name_style`;字段不存在表示当前无有效装扮。到达 `expires_at_ms` 时客户端立即隐藏,并刷新房间详情。 +房间列表、feed、详情和命令响应会直接返回字符串 `room_border_url` 以及结构化 `room_border`、`room_name_style`;`room_border_url` 为空表示无有效边框。到达 `expires_at_ms` 时客户端立即隐藏,并刷新房间详情。 ### 7.1 装扮 IM diff --git a/docs/flutter对接/幸运礼物Flutter对接.md b/docs/flutter对接/幸运礼物Flutter对接.md index b4c47373..4e0b7174 100644 --- a/docs/flutter对接/幸运礼物Flutter对接.md +++ b/docs/flutter对接/幸运礼物Flutter对接.md @@ -215,6 +215,7 @@ Content-Type: application/json | `target_user_id` | string/int64 | 收礼用户 ID。 | | `gift_value` | int64 | 本次送礼房间价值/热度口径。 | | `room_heat` | int64 | 送礼后的房间热度。 | +| `sender_vip_level` | string | 送礼人发送瞬间的生效 VIP 等级;`0` 或缺失表示无有效 VIP。 | | `attributes.gift_id` | string | 礼物 ID。 | | `attributes.pool_id` | string | 幸运礼物奖池 ID;普通礼物可为空。 | | `attributes.gift_count` | string | 礼物数量,字符串格式。 | @@ -236,6 +237,7 @@ Content-Type: application/json "target_user_id": "10002", "gift_value": 900, "room_heat": 123456, + "sender_vip_level": "7", "attributes": { "gift_id": "rose", "pool_id": "super_lucky", @@ -243,11 +245,14 @@ Content-Type: application/json "billing_receipt_id": "receipt-lucky", "coin_spent": "900", "gift_point_added": "900", - "price_version": "v1" + "price_version": "v1", + "sender_vip_level": "7" } } ``` +`RoomEvent` 会把 `attributes` 展示字段同时铺到顶层。Flutter 优先解析顶层 `sender_vip_level`,兼容回退到 `attributes.sender_vip_level`,使用 `int.tryParse` 转为等级;后端不下发 VIP 色值。 + ### 3.2 幸运礼物开奖 IM 用途:房间公屏展示幸运礼物中奖结果、其他用户同步中奖表现。当前用户自己的余额以 HTTP 返回的 `coin_balance_after` 为准,不等这条 IM。 diff --git a/docs/flutter对接/房间列表房主VIP等级Flutter对接.md b/docs/flutter对接/房间列表房主VIP等级Flutter对接.md new file mode 100644 index 00000000..07a22107 --- /dev/null +++ b/docs/flutter对接/房间列表房主VIP等级Flutter对接.md @@ -0,0 +1,310 @@ +# 房间列表房主 VIP 与房间边框 Flutter 对接 + +本文档描述房间卡片的房主 VIP 等级、房间边框 URL,以及房主保存边框资源的接口。服务端不返回 VIP 房名色值;房间边框 URL 来自已授权的 `room_border` 资源。 + +## 接口范围 + +公共房间列表和 Mine 房间关系流返回相同的房间卡片结构: + +```http +GET /api/v1/rooms?tab=hot&limit=20 +GET /api/v1/rooms?tab=new&limit=20 +GET /api/v1/rooms/feeds?tab=visited&limit=20 +GET /api/v1/rooms/feeds?tab=friend&limit=20 +GET /api/v1/rooms/feeds?tab=following&limit=20 +GET /api/v1/rooms/feeds?tab=followed&limit=20 +Authorization: Bearer +``` + +房间卡片字段位于: + +```text +data.rooms[*].owner_vip_level +data.rooms[*].room_border_url +``` + +列表不需要 Flutter 针对每个房主调用 `/api/v1/vip/me`、资源或房间详情接口。房主选择边框时使用本文后面的专用保存接口。 + +## 公共房间列表参数 + +| 参数 | 必填 | 说明 | +| --- | --- | --- | +| `tab` | 否 | `hot` 或 `new`,不传默认 `hot`。 | +| `cursor` | 否 | 服务端返回的不透明分页游标;下一页原样回传。 | +| `limit` | 否 | 默认 20,最大 50。 | +| `q` | 否 | 房间标题或用户 ID 搜索词;新客户端使用 `q`。 | +| `region_id` | 否 | 仅开放跨区域筛选的 App 使用;与 `country_code` 互斥。 | +| `country_code` | 否 | 大写国家码;与 `region_id` 互斥。 | + +`region_id`、`country_code` 的可选值必须来自 `GET /api/v1/rooms/filters`,不要由客户端自行拼接。切换 tab、区域、国家或搜索词时必须清空旧 `cursor`。 + +## 成功响应 + +```json +{ + "code": "OK", + "message": "ok", + "request_id": "req_room_list_01", + "data": { + "rooms": [ + { + "room_id": "fami_room_1001", + "im_group_id": "fami_room_1001", + "owner_user_id": "10001", + "owner_vip_level": 7, + "room_border_url": "https://cdn.example.com/room-border/gold.webp", + "title": "Nina Room", + "cover_url": "https://cdn.example.com/room/1001.png", + "mode": "voice", + "status": "active", + "locked": false, + "heat": 9820, + "online_count": 18, + "seat_count": 10, + "occupied_seat_count": 4, + "visible_region_id": 1001, + "app_code": "fami", + "room_short_id": "1001", + "country_code": "AE", + "country_flag": "🇦🇪" + }, + { + "room_id": "fami_room_1002", + "im_group_id": "fami_room_1002", + "owner_user_id": "10002", + "owner_vip_level": 0, + "room_border_url": "", + "title": "Music Room", + "mode": "voice", + "status": "active", + "locked": true, + "heat": 1200, + "online_count": 3, + "seat_count": 10, + "occupied_seat_count": 1 + } + ], + "next_cursor": "eyJ0YWIiOiJob3QiLC4uLn0" + } +} +``` + +## `owner_vip_level` 语义 + +| 值 | Flutter 行为 | +| --- | --- | +| `0` | 房主当前没有有效 VIP,使用普通房间名称样式。 | +| `> 0` | 房主当前有效 VIP 等级,使用当前 App 对应等级的 VIP 房间名称样式。 | +| 字段缺失或 `null` | 仅用于兼容旧环境、旧缓存或 Mock 数据,按 `0` 处理。 | + +边界要求: + +- 该值是房主的当前有效 VIP 等级,只用于房间卡片展示。 +- 服务端已经处理 VIP 到期;Flutter 不需要计算到期时间。 +- 接口不返回 `text_colors`、十六进制色值、渐变角度或 VIP 到期时间。 +- Flutter 不要根据 `owner_user_id`、房间热度或本地购买记录推断 VIP。 +- 不要把该展示字段用于权限判断;购买、装扮、踢人保护等权限仍以对应后端接口结果为准。 +- 不要硬编码最大等级。不同 App 的 VIP 等级数量可以不同,未知正等级应走产品约定的默认 VIP 样式兜底。 + +房间卡片还可能返回独立资源装扮字段 `room_name_style`。它属于用户主动佩戴的资源装扮,与本次 `owner_vip_level` 驱动的默认 VIP 房名展示不是同一个契约;Flutter 不要用 `room_name_style` 是否存在来判断房主有没有 VIP。 + +## `room_border_url` 语义 + +| 值 | Flutter 行为 | +| --- | --- | +| 非空 URL | 渲染当前房间边框。 | +| 空字符串、字段缺失或 `null` | 当前没有有效房间边框,不渲染。 | + +- 字段类型固定为字符串,是当前有效 `room_border.asset_url` 的扁平值。 +- 服务端已经过滤到期、撤销和无效资源;Flutter 仍应在收到 `room_decoration_changed` 或刷新列表后覆盖旧值。 +- 列表查询直接读取现有房间装扮投影,不增加 wallet 查询,也不逐房回查资源。 +- 兼容旧服务时可以回退读取 `room_border.asset_url`,但新代码应优先使用 `room_border_url`。 +- URL 只用于展示,不能作为资源所有权或 VIP 权限判断依据。 + +## Dart Model + +```dart +class RoomListItem { + const RoomListItem({ + required this.roomId, + required this.imGroupId, + required this.ownerUserId, + required this.ownerVipLevel, + required this.roomBorderUrl, + required this.title, + required this.locked, + }); + + final String roomId; + final String imGroupId; + final String ownerUserId; + final int ownerVipLevel; + final String roomBorderUrl; + final String title; + final bool locked; + + bool get hasOwnerVip => ownerVipLevel > 0; + bool get hasRoomBorder => roomBorderUrl.isNotEmpty; + + factory RoomListItem.fromJson(Map json) { + return RoomListItem( + roomId: json['room_id']?.toString() ?? '', + imGroupId: json['im_group_id']?.toString() ?? '', + ownerUserId: json['owner_user_id']?.toString() ?? '', + ownerVipLevel: _readInt(json['owner_vip_level']), + roomBorderUrl: json['room_border_url']?.toString() ?? + ((json['room_border'] as Map?)?['asset_url'] + ?.toString() ?? + ''), + title: json['title']?.toString() ?? '', + locked: json['locked'] == true, + ); + } + + static int _readInt(dynamic value) { + if (value is num) return value.toInt(); + return int.tryParse(value?.toString() ?? '') ?? 0; + } +} +``` + +用户 ID、房间 ID 和 IM 群 ID 按字符串保存,不要转成 Dart `int`。 + +## 保存房间边框 + +先从用户资源接口选择一个当前可用的房间边框: + +```http +GET /api/v1/users/me/resources?resource_type=room_border +Authorization: Bearer +``` + +然后由房主保存到指定房间: + +```http +POST /api/v1/rooms/{room_id}/border/save +Authorization: Bearer +Content-Type: application/json + +{ + "command_id": "room_border_550e8400-e29b-41d4-a716-446655440000", + "resource_id": "5201", + "entitlement_id": "ent_room_border_abc" +} +``` + +| 字段 | 必填 | 说明 | +| --- | --- | --- | +| `command_id` | 是 | 幂等键,最大 128 字节;同一次保存重试必须复用。 | +| `resource_id` | 是 | 从用户资源接口取得,支持 JSON 数字或数字字符串。 | +| `entitlement_id` | 否 | 建议回传选中的资源实例 ID;省略时服务端选择当前有效实例。 | + +客户端不提交 `room_border_url`。服务端会校验房主身份、房间状态、VIP benefit、资源类型、资源状态和 entitlement,再把资源的权威 `asset_url` 保存进 Room Cell。 + +成功响应: + +```json +{ + "code": "OK", + "message": "ok", + "request_id": "req_save_room_border", + "data": { + "result": { + "applied": true, + "room_version": 21, + "server_time_ms": 1784515200000 + }, + "room": { + "room_id": "fami_room_1001", + "room_border_url": "https://cdn.example.com/room-border/gold.webp", + "version": 21 + }, + "room_border_url": "https://cdn.example.com/room-border/gold.webp", + "resource": { + "resource_id": 5201, + "resource_type": "room_border", + "asset_url": "https://cdn.example.com/room-border/gold.webp", + "entitlement_id": "ent_room_border_abc" + }, + "server_time_ms": 1784515200000 + } +} +``` + +保存成功后立即使用 `data.room_border_url` 更新当前设备。其他房间用户通过 `room_decoration_changed` 更新:当 `decoration_type=room_border` 且 `resource_id>0` 时优先使用 `room_border_url`,兼容读取 `asset_url`;当 `resource_id=0` 时清空边框。 + +## Dio 请求示例 + +```dart +Future> fetchRooms({ + String tab = 'hot', + String? cursor, +}) async { + final response = await dio.get>( + '/api/v1/rooms', + queryParameters: { + 'tab': tab, + 'limit': 20, + if (cursor != null && cursor.isNotEmpty) 'cursor': cursor, + }, + ); + + final envelope = response.data ?? const {}; + if (envelope['code'] != 'OK') { + throw StateError(envelope['message']?.toString() ?? 'room list failed'); + } + + final data = envelope['data'] as Map? ?? + const {}; + final rooms = data['rooms'] as List? ?? const []; + return rooms + .whereType>() + .map(RoomListItem.fromJson) + .toList(growable: false); +} +``` + +## UI 展示规则 + +```dart +final TextStyle roomNameStyle = room.ownerVipLevel > 0 + ? vipRoomNameStyleResolver.resolve(room.ownerVipLevel) + : defaultRoomNameStyle; + +Text( + room.title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: roomNameStyle, +); +``` + +`vipRoomNameStyleResolver` 使用当前 App 已有的 VIP 设计 token。本文档不定义具体颜色,后端也不会通过本字段下发颜色。 + +## 刷新与一致性 + +- VIP 购买、体验卡切换或房主重新进房后,服务端异步刷新房间列表读模型。 +- Flutter 继续使用现有的下拉刷新、切换 tab、回前台刷新和分页流程,不新增逐房轮询。 +- 同一个房间已经显示在页面上时,以后续房间列表请求返回的新 `owner_vip_level` 覆盖本地旧值。 +- `owner_vip_level` 从正数变成 `0` 时,立即恢复普通房名样式。 +- `room_border_url` 变化时替换边框;变成空字符串时立即移除边框。 + +## 错误处理 + +| HTTP | `code` | Flutter 处理 | +| --- | --- | --- | +| 400 | `INVALID_ARGUMENT` | 参数或筛选组合错误;清空无效 cursor 后按当前筛选重新请求。 | +| 401 | `UNAUTHORIZED` | 刷新登录态或跳转登录。 | +| 403 | `PERMISSION_DENIED` | 按现有资料完整度或权限流程处理。 | +| 502 | `UPSTREAM_ERROR` | 保留当前列表,提供重试;不要逐房查询 VIP 兜底。 | + +## Flutter 验收清单 + +- `owner_vip_level=0` 使用普通房名样式。 +- `owner_vip_level>0` 使用对应等级的 VIP 房名样式。 +- 字段缺失、`null` 或无法解析时按 `0` 兼容,房间卡片仍正常展示。 +- 翻页、搜索、公共列表和 Mine 关系流使用同一字段解析逻辑。 +- 不请求每个房主的 VIP 详情,不在客户端拼接色值接口。 +- VIP 等级变化后,下一次列表刷新能覆盖当前卡片样式。 +- 非空 `room_border_url` 能渲染房间边框,空值不占位、不发起空 URL 请求。 +- 保存边框只提交资源身份,成功后使用服务端返回 URL;客户端不允许保存任意外部 URL。 diff --git a/docs/flutter对接/礼物TabFlutter对接.md b/docs/flutter对接/礼物TabFlutter对接.md index 8bb6c2f6..e10af994 100644 --- a/docs/flutter对接/礼物TabFlutter对接.md +++ b/docs/flutter对接/礼物TabFlutter对接.md @@ -362,10 +362,12 @@ Content-Type: application/json "actor_user_id": "10001", "target_user_id": "10002", "gift_value": 900, + "sender_vip_level": "7", "attributes": { "gift_id": "rose", "gift_count": "9", - "billing_receipt_id": "br_abc" + "billing_receipt_id": "br_abc", + "sender_vip_level": "7" } } ``` @@ -377,9 +379,25 @@ Content-Type: application/json - `actor_user_id` 是送礼人,`target_user_id` 是收礼人。 - `attributes.gift_id` 对应面板里的礼物 ID;本地找不到配置时展示兜底礼物图标。 - `attributes.gift_count` 当前是字符串,Flutter 解析时需要 `int.tryParse`。 +- `sender_vip_level` 是送礼人发送瞬间的生效 VIP 等级,腾讯云 IM JSON 中是字符串;`0` 或字段缺失表示当前无有效 VIP。 +- `RoomEvent` 会把展示属性同时铺到顶层并保留在 `attributes`;Flutter 优先读取顶层 `sender_vip_level`,兼容读取 `attributes.sender_vip_level`。 +- 后端不返回 VIP 房间名称色值;Flutter 只按等级映射客户端已有 VIP 样式。 +- 多人送礼事件 `room_gift_batch_sent` 使用完全相同的 `sender_vip_level` 语义和解析方式。 - `gift_value` 是本次房间价值/热度口径,不等同于客户端自己算的 `coin_price * count`。 - 自己发送的礼物如果已经由 HTTP 成功响应播放过,收到同一 `command_id` 或短时间同一礼物 IM 时要做播放去重。 +Flutter 兼容解析: + +```dart +int parseSenderVipLevel(Map data) { + final attributes = data['attributes']; + final raw = data['sender_vip_level'] ?? + (attributes is Map ? attributes['sender_vip_level'] : null); + if (raw is num) return raw.toInt(); + return int.tryParse(raw?.toString() ?? '') ?? 0; +} +``` + ## 错误处理 外层 envelope: diff --git a/docs/openapi/gateway.swagger.yaml b/docs/openapi/gateway.swagger.yaml index 6d125f7d..5c2b5cff 100644 --- a/docs/openapi/gateway.swagger.yaml +++ b/docs/openapi/gateway.swagger.yaml @@ -2295,6 +2295,44 @@ paths: $ref: "#/responses/Conflict" "502": $ref: "#/responses/UpstreamError" + /api/v1/rooms/{room_id}/border/save: + post: + tags: + - rooms + summary: 保存当前房间边框资源 + operationId: saveRoomBorder + description: 房主专用;复用 room_border 的 VIP benefit、资源目录和 entitlement 校验,成功后由 Room Cell 持久化并返回字符串 room_border_url。客户端不能提交任意 URL。 + security: + - BearerAuth: [] + parameters: + - name: room_id + in: path + required: true + type: string + - name: body + in: body + required: true + schema: + $ref: "#/definitions/SaveRoomBorderRequest" + responses: + "200": + description: 保存成功或幂等重放;随后异步投递 room_decoration_changed。 + schema: + $ref: "#/definitions/SaveRoomBorderEnvelope" + "400": + $ref: "#/responses/BadRequest" + "401": + $ref: "#/responses/Unauthorized" + "403": + description: 非房主或 room_border VIP 权益未生效。 + schema: + $ref: "#/definitions/ErrorEnvelope" + "404": + $ref: "#/responses/NotFound" + "409": + $ref: "#/responses/Conflict" + "502": + $ref: "#/responses/UpstreamError" /api/v1/rooms/{room_id}/messages/images/upload: post: tags: @@ -5541,6 +5579,9 @@ definitions: format: int32 room_border: $ref: "#/definitions/RoomDecorationData" + room_border_url: + type: string + description: 当前有效 room_border.asset_url 的扁平字段;空字符串表示没有有效房间边框。 room_name_style: $ref: "#/definitions/RoomDecorationData" active_background: @@ -5620,6 +5661,7 @@ definitions: required: - room_id - im_group_id + - room_border_url properties: room_id: type: string @@ -5668,6 +5710,11 @@ definitions: country_flag: type: string description: 根据 `country_code` 派生的国旗 emoji;无有效国家投影时省略。 + room_border_url: + type: string + description: 当前有效房间边框资源的 asset_url;空字符串表示无边框。直接来自现有列表装扮投影,不触发 wallet 回查。 + room_border: + $ref: "#/definitions/RoomDecorationData" RoomListData: type: object properties: @@ -5827,6 +5874,7 @@ definitions: - online_count - seat_count - version + - room_border_url properties: room_id: type: string @@ -5873,6 +5921,9 @@ definitions: format: int64 active_background: $ref: "#/definitions/RoomBackgroundData" + room_border_url: + type: string + description: 当前有效房间边框资源 URL;空字符串表示无边框。 room_border: $ref: "#/definitions/RoomDecorationData" room_name_style: @@ -7786,6 +7837,49 @@ definitions: entitlement_id: type: string description: 可选;建议传背包实例 ID,省略时服务端选择一个当前有效实例。 + SaveRoomBorderRequest: + type: object + required: + - command_id + - resource_id + properties: + command_id: + type: string + maxLength: 128 + description: 保存动作幂等键;重试必须复用。 + resource_id: + type: string + pattern: "^[1-9][0-9]*$" + description: 从用户 room_border 资源列表取得;支持 JSON 数字或数字字符串。 + entitlement_id: + type: string + description: 可选;建议传选中的有效资源实例 ID。 + SaveRoomBorderData: + type: object + required: + - result + - room + - room_border_url + - server_time_ms + properties: + result: + $ref: "#/definitions/CommandResult" + room: + $ref: "#/definitions/RoomInitialData" + room_border_url: + type: string + resource: + $ref: "#/definitions/RoomDecorationData" + server_time_ms: + type: integer + format: int64 + SaveRoomBorderEnvelope: + allOf: + - $ref: "#/definitions/GatewayOKEnvelopeBase" + - type: object + properties: + data: + $ref: "#/definitions/SaveRoomBorderData" ApplyRoomDecorationData: type: object properties: diff --git a/docs/openapi/room.swagger.yaml b/docs/openapi/room.swagger.yaml index 19245d9d..c51983a4 100644 --- a/docs/openapi/room.swagger.yaml +++ b/docs/openapi/room.swagger.yaml @@ -633,6 +633,9 @@ definitions: version: type: integer format: int64 + room_border_url: + type: string + description: 当前有效 room_border.asset_url 的扁平字段;空字符串表示没有有效边框。 RoomBanState: type: object properties: diff --git a/docs/接口清单.md b/docs/接口清单.md index 72ae2709..b32c1a14 100644 --- a/docs/接口清单.md +++ b/docs/接口清单.md @@ -59,9 +59,9 @@ | GET | `/api/v1/users/me/friends` | users | `listMyFriends` | 我的好友列表 | | DELETE | `/api/v1/users/{user_id}/friend` | users | `deleteFriend` | 双向删除好友关系 | | GET | `/api/v1/rooms/filters` | rooms | `listRoomFilters` | Flutter 首页区域/国家筛选目录,下发可原样回传的 `region_id/country_code` | -| GET | `/api/v1/rooms` | rooms | `listRooms` | 房间发现列表;Huwaa 默认 All,支持互斥的 `region_id/country_code` 及 `tab=hot/new`,卡片返回 `im_group_id` | +| GET | `/api/v1/rooms` | rooms | `listRooms` | 房间发现列表;Huwaa 默认 All,支持互斥的 `region_id/country_code` 及 `tab=hot/new`,卡片返回 `im_group_id`、房主 `owner_vip_level` 和 `room_border_url` | | GET | `/api/v1/rooms/me` | rooms | `getMyRoom` | 查询 Mine 顶部我的房间卡片 | -| GET | `/api/v1/rooms/feeds` | rooms | `listRoomFeeds` | Mine 页 Visited/Friend/Following/Followed 房间流 | +| GET | `/api/v1/rooms/feeds` | rooms | `listRoomFeeds` | Mine 页 Visited/Friend/Following/Followed 房间流,复用包含 `owner_vip_level/room_border_url` 的房间卡片结构 | | GET | `/api/v1/rooms/current` | rooms | `getCurrentRoom` | 查询当前可恢复房间 | | GET | `/api/v1/rooms/snapshot` | rooms | `getRoomSnapshot` | 查询房间完整快照 | | GET | `/api/v1/rooms/{room_id}/detail` | rooms | `getRoomDetail` | 查询房间详情页首屏聚合包 | @@ -74,6 +74,7 @@ | POST | `/api/v1/rooms/profile/update` | rooms | `updateRoomProfile` | 修改房间资料和座位数 | | POST | `/api/v1/rooms/backgrounds/save` | rooms | `saveRoomBackground` | 房主保存房间背景图 URL 到素材列表 | | POST | `/api/v1/rooms/backgrounds/set` | rooms | `setRoomBackground` | 房主设置当前房间背景图 | +| POST | `/api/v1/rooms/{room_id}/border/save` | rooms | `saveRoomBorder` | 房主把已授权的 `room_border` 资源保存为当前房间边框并返回字符串 URL | | POST | `/api/v1/rooms/join` | rooms | `joinRoom` | 加入房间并返回首屏数据和 RTC token | | POST | `/api/v1/rooms/heartbeat` | rooms | `roomHeartbeat` | 刷新房间业务 presence | | POST | `/api/v1/rooms/leave` | rooms | `leaveRoom` | 离开房间 | diff --git a/services/gateway-service/internal/transport/http/httproutes/router.go b/services/gateway-service/internal/transport/http/httproutes/router.go index 68d8beca..078f3faa 100644 --- a/services/gateway-service/internal/transport/http/httproutes/router.go +++ b/services/gateway-service/internal/transport/http/httproutes/router.go @@ -182,6 +182,7 @@ type RoomHandlers struct { SetRoomBackground http.HandlerFunc UploadRoomBackground http.HandlerFunc UploadRoomImage http.HandlerFunc + SaveRoomBorder http.HandlerFunc ApplyRoomDecoration http.HandlerFunc SendRoomImage http.HandlerFunc JoinRoom http.HandlerFunc @@ -579,6 +580,7 @@ func (r routes) registerRoomRoutes() { r.profile("/rooms/{room_id}/backgrounds/upload", http.MethodPost, h.UploadRoomBackground) r.profile("/rooms/{room_id}/messages/images/upload", http.MethodPost, h.UploadRoomImage) r.profile("/rooms/{room_id}/messages/image", http.MethodPost, h.SendRoomImage) + r.profile("/rooms/{room_id}/border/save", http.MethodPost, h.SaveRoomBorder) r.profile("/rooms/{room_id}/decorations/apply", http.MethodPost, h.ApplyRoomDecoration) r.profile("/rooms/create", http.MethodPost, h.CreateRoom) r.profile("/rooms/profile/update", http.MethodPost, h.UpdateRoomProfile) diff --git a/services/gateway-service/internal/transport/http/response_test.go b/services/gateway-service/internal/transport/http/response_test.go index 9e0dcde3..a7570ce4 100644 --- a/services/gateway-service/internal/transport/http/response_test.go +++ b/services/gateway-service/internal/transport/http/response_test.go @@ -72,6 +72,30 @@ type fakeRoomClient struct { joinResp *roomv1.JoinRoomResponse } +type fakeRoomVIPClient struct { + *fakeRoomClient + lastApplyDecoration *roomv1.ApplyRoomDecorationRequest +} + +func (f *fakeRoomVIPClient) ApplyRoomDecoration(_ context.Context, req *roomv1.ApplyRoomDecorationRequest) (*roomv1.ApplyRoomDecorationResponse, error) { + f.lastApplyDecoration = req + resource := &roomv1.RoomDecorationResource{ + ResourceId: req.GetResourceId(), ResourceCode: "vip-room-border-gold", ResourceType: "room_border", + Name: "Gold Room Border", AssetUrl: "https://cdn.example.com/room-border/gold.webp", EntitlementId: req.GetEntitlementId(), + } + return &roomv1.ApplyRoomDecorationResponse{ + Result: &roomv1.CommandResult{Applied: true, RoomVersion: 5, ServerTimeMs: 1_700_000_000_005}, + Room: &roomv1.RoomSnapshot{ + RoomId: req.GetMeta().GetRoomId(), OwnerUserId: req.GetMeta().GetActorUserId(), Status: "active", RoomBorder: resource, Version: 5, + }, + DecorationType: req.GetDecorationType(), Resource: resource, ServerTimeMs: 1_700_000_000_005, + }, nil +} + +func (f *fakeRoomVIPClient) SendRoomImage(_ context.Context, _ *roomv1.SendRoomImageRequest) (*roomv1.SendRoomImageResponse, error) { + return &roomv1.SendRoomImageResponse{}, nil +} + func (f *fakeRoomClient) CreateRoom(_ context.Context, req *roomv1.CreateRoomRequest) (*roomv1.CreateRoomResponse, error) { f.lastCreate = req if f.createErr != nil { @@ -3389,6 +3413,9 @@ func TestBatchSendGiftForwardsDisplayModeAndResponse(t *testing.T) { func TestSendGiftInjectsDisplayProfilesForIMSnapshot(t *testing.T) { roomClient := &fakeRoomClient{} + walletClient := &fakeWalletClient{myVipResp: &walletv1.GetMyVipResponse{State: &walletv1.VipState{ + EffectiveVip: &walletv1.UserVip{UserId: 42, Level: 7, Active: true}, + }}} profileClient := &fakeUserProfileClient{usersByID: map[int64]*userv1.User{ 42: { UserId: 42, @@ -3409,7 +3436,9 @@ func TestSendGiftInjectsDisplayProfilesForIMSnapshot(t *testing.T) { RegionId: 1001, }, }} - router := NewHandlerWithClients(roomClient, nil, nil, profileClient).Routes(auth.NewVerifier("secret")) + handler := NewHandlerWithClients(roomClient, nil, nil, profileClient) + handler.SetWalletClient(walletClient) + router := handler.Routes(auth.NewVerifier("secret")) request := httptest.NewRequest(http.MethodPost, "/api/v1/rooms/gift/send", bytes.NewReader([]byte(`{"room_id":"room-1","command_id":"cmd-gift-profile","target_user_id":43,"gift_id":"rose","gift_count":1}`))) request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42)) recorder := httptest.NewRecorder() @@ -3420,7 +3449,7 @@ func TestSendGiftInjectsDisplayProfilesForIMSnapshot(t *testing.T) { t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String()) } sender := roomClient.lastGift.GetSenderDisplayProfile() - if sender.GetUserId() != 42 || sender.GetUsername() != "Robot Sender" || sender.GetAvatar() == "" || sender.GetDisplayUserId() != "100042" || sender.GetPrettyDisplayUserId() != "888042" { + if sender.GetUserId() != 42 || sender.GetUsername() != "Robot Sender" || sender.GetAvatar() == "" || sender.GetDisplayUserId() != "100042" || sender.GetPrettyDisplayUserId() != "888042" || sender.GetEffectiveVipLevel() != 7 { t.Fatalf("gateway must inject sender display profile snapshot: %+v", sender) } targets := roomClient.lastGift.GetTargetDisplayProfiles() @@ -3430,6 +3459,9 @@ func TestSendGiftInjectsDisplayProfilesForIMSnapshot(t *testing.T) { if profileClient.lastGet != nil || len(profileClient.batchRequests) != 1 || len(profileClient.lastBatch.GetUserIds()) != 2 || profileClient.lastBatch.GetUserIds()[0] != 42 || profileClient.lastBatch.GetUserIds()[1] != 43 { t.Fatalf("gift user snapshots must use one batch profile request: get=%+v batch=%+v calls=%d", profileClient.lastGet, profileClient.lastBatch, len(profileClient.batchRequests)) } + if walletClient.lastMyVip.GetUserId() != 42 { + t.Fatalf("gift sender VIP snapshot must query the authenticated sender: %+v", walletClient.lastMyVip) + } } func TestSendGiftInjectsHostPeriodScopeForEachTarget(t *testing.T) { @@ -3888,7 +3920,10 @@ func TestListRoomsUsesUserRegionFromUserService(t *testing.T) { }} queryClient := &fakeRoomQueryClient{resp: &roomv1.ListRoomsResponse{ Rooms: []*roomv1.RoomListItem{ - {RoomId: "room-1", VisibleRegionId: 1001, Heat: 90, Locked: true, CountryCode: "US", OwnerVipLevel: 5}, + { + RoomId: "room-1", VisibleRegionId: 1001, Heat: 90, Locked: true, CountryCode: "US", OwnerVipLevel: 5, + RoomBorder: &roomv1.RoomDecorationResource{ResourceId: 5201, ResourceType: "room_border", AssetUrl: "https://cdn.example.com/room-border/gold.webp"}, + }, }, NextCursor: "cursor-2", }} @@ -3939,11 +3974,39 @@ func TestListRoomsUsesUserRegionFromUserService(t *testing.T) { } rooms := data["rooms"].([]any) first := rooms[0].(map[string]any) - if first["im_group_id"] != "room-1" || first["locked"] != true || first["country_code"] != "US" || first["owner_vip_level"] != float64(5) { + if first["im_group_id"] != "room-1" || first["locked"] != true || first["country_code"] != "US" || first["owner_vip_level"] != float64(5) || first["room_border_url"] != "https://cdn.example.com/room-border/gold.webp" { t.Fatalf("room list must expose room IM group id: %+v", first) } } +func TestSaveRoomBorderAppliesOwnedResourceAndReturnsFlatURL(t *testing.T) { + client := &fakeRoomVIPClient{fakeRoomClient: &fakeRoomClient{}} + router := NewHandler(client).Routes(auth.NewVerifier("secret")) + request := httptest.NewRequest(http.MethodPost, "/api/v1/rooms/room-1/border/save", bytes.NewReader([]byte(`{"command_id":"cmd-save-border","resource_id":"5201","entitlement_id":"ent-border-1"}`))) + request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42)) + request.Header.Set("X-Request-ID", "req-save-border") + recorder := httptest.NewRecorder() + + router.ServeHTTP(recorder, request) + + if recorder.Code != http.StatusOK { + t.Fatalf("save room border status mismatch: got=%d body=%s", recorder.Code, recorder.Body.String()) + } + forwarded := client.lastApplyDecoration + if forwarded == nil || forwarded.GetMeta().GetRoomId() != "room-1" || forwarded.GetMeta().GetActorUserId() != 42 || forwarded.GetMeta().GetCommandId() != "cmd-save-border" || forwarded.GetDecorationType() != "room_border" || forwarded.GetResourceId() != 5201 || forwarded.GetEntitlementId() != "ent-border-1" { + t.Fatalf("save room border must reuse authoritative decoration command: %+v", forwarded) + } + var envelope httpkit.ResponseEnvelope + if err := json.NewDecoder(recorder.Body).Decode(&envelope); err != nil { + t.Fatalf("decode save room border response failed: %v", err) + } + data := envelope.Data.(map[string]any) + room := data["room"].(map[string]any) + if data["room_border_url"] != "https://cdn.example.com/room-border/gold.webp" || room["room_border_url"] != "https://cdn.example.com/room-border/gold.webp" { + t.Fatalf("save room border response must expose flat URL: %+v", data) + } +} + func TestListRoomsHuwaaDefaultsToAllRegionsAndSupportsValidatedFilters(t *testing.T) { newHandler := func(regionClient *fakeUserRegionClient, countryClient *fakeUserCountryQueryClient, queryClient *fakeRoomQueryClient) http.Handler { handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, &fakeUserProfileClient{regionID: 1001}) diff --git a/services/gateway-service/internal/transport/http/roomapi/handler.go b/services/gateway-service/internal/transport/http/roomapi/handler.go index 2321900f..dda6d831 100644 --- a/services/gateway-service/internal/transport/http/roomapi/handler.go +++ b/services/gateway-service/internal/transport/http/roomapi/handler.go @@ -156,6 +156,7 @@ func (h *Handler) RoomHandlers() httproutes.RoomHandlers { SetRoomBackground: h.setRoomBackground, UploadRoomBackground: h.uploadRoomBackground, UploadRoomImage: h.uploadRoomImage, + SaveRoomBorder: h.saveRoomBorder, ApplyRoomDecoration: h.applyRoomDecoration, SendRoomImage: h.sendRoomImage, JoinRoom: h.joinRoom, 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 6a7a8633..38e42e2b 100644 --- a/services/gateway-service/internal/transport/http/roomapi/room_handler.go +++ b/services/gateway-service/internal/transport/http/roomapi/room_handler.go @@ -49,6 +49,8 @@ const ( joinRoomResponseModeLite = "lite" // 座驾只影响进房展示,独立短预算避免 wallet 抖动把 lite 首屏拖到通用 5 秒 RPC 上限。 joinRoomEntryVehicleLookupTimeout = 100 * time.Millisecond + // VIP 只增强送礼 IM 展示;短预算并发查询,wallet 抖动时降级为 0,不能阻断变现主链路。 + giftSenderVIPLookupTimeout = 500 * time.Millisecond ) type flexibleInt64 int64 @@ -2038,12 +2040,7 @@ func (h *Handler) handleSendGift(writer http.ResponseWriter, request *http.Reque defer cancelGift() request = request.WithContext(giftCtx) - userSnapshots, err := h.resolveGiftUserSnapshots(request, targetUserIDs) - if err != nil { - httpkit.WriteRPCError(writer, request, err) - return - } - targetHostScopes, err := h.resolveGiftTargetHostScopes(request, targetUserIDs) + userSnapshots, targetHostScopes, err := h.resolveGiftPreflight(request, targetUserIDs) if err != nil { httpkit.WriteRPCError(writer, request, err) return @@ -2157,6 +2154,82 @@ type giftUserSnapshots struct { targetDisplayProfiles []*roomv1.SendGiftDisplayProfile } +func (h *Handler) resolveGiftPreflight(request *http.Request, targetUserIDs []int64) (giftUserSnapshots, []*roomv1.SendGiftTargetHostScope, error) { + // 用户资料、主播工资归属和发送者 VIP 互不依赖。并发取得三份入站快照,让新增 VIP 不成为 + // 原有 profile -> host 串行链路之后的额外等待;任一结算必需查询失败时取消同批请求。 + preflightCtx, cancelPreflight := context.WithCancel(request.Context()) + defer cancelPreflight() + preflightRequest := request.WithContext(preflightCtx) + + var waitGroup sync.WaitGroup + var userSnapshots giftUserSnapshots + var userSnapshotsErr error + var targetHostScopes []*roomv1.SendGiftTargetHostScope + var targetHostScopesErr error + var senderVIPLevel int32 + waitGroup.Add(3) + go func() { + defer waitGroup.Done() + userSnapshots, userSnapshotsErr = h.resolveGiftUserSnapshots(preflightRequest, targetUserIDs) + if userSnapshotsErr != nil { + cancelPreflight() + } + }() + go func() { + defer waitGroup.Done() + targetHostScopes, targetHostScopesErr = h.resolveGiftTargetHostScopes(preflightRequest, targetUserIDs) + if targetHostScopesErr != nil { + cancelPreflight() + } + }() + go func() { + defer waitGroup.Done() + senderVIPLevel = h.resolveGiftSenderVIPLevelBestEffort(preflightRequest) + }() + waitGroup.Wait() + + if userSnapshotsErr != nil { + return giftUserSnapshots{}, nil, userSnapshotsErr + } + if targetHostScopesErr != nil { + return giftUserSnapshots{}, nil, targetHostScopesErr + } + if userSnapshots.senderDisplayProfile != nil { + // VIP 属于发送者展示快照;room-service 会把整份快照持久化,重试和 Saga 恢复不再回查当前等级。 + userSnapshots.senderDisplayProfile.EffectiveVipLevel = senderVIPLevel + } + return userSnapshots, targetHostScopes, nil +} + +func (h *Handler) resolveGiftSenderVIPLevelBestEffort(request *http.Request) int32 { + if h.walletClient == nil { + return 0 + } + senderUserID := auth.UserIDFromContext(request.Context()) + if senderUserID <= 0 { + return 0 + } + vipCtx, cancelVIP := context.WithTimeout(request.Context(), giftSenderVIPLookupTimeout) + defer cancelVIP() + resp, err := h.walletClient.GetMyVip(vipCtx, &walletv1.GetMyVipRequest{ + RequestId: httpkit.RequestIDFromContext(request.Context()), + AppCode: appcode.FromContext(request.Context()), + UserId: senderUserID, + }) + if err != nil || resp == nil { + return 0 + } + effectiveVIP := resp.GetState().GetEffectiveVip() + if effectiveVIP == nil { + // GetMyVip.vip 历史上就是 effective VIP;保留滚动发布期间旧 wallet-service 的响应兼容。 + effectiveVIP = resp.GetVip() + } + if !effectiveVIP.GetActive() || effectiveVIP.GetLevel() <= 0 || (effectiveVIP.GetUserId() > 0 && effectiveVIP.GetUserId() != senderUserID) { + return 0 + } + return effectiveVIP.GetLevel() +} + func (h *Handler) resolveGiftUserSnapshots(request *http.Request, targetUserIDs []int64) (giftUserSnapshots, error) { if h.userProfileClient == nil { return giftUserSnapshots{}, xerr.ToGRPCError(xerr.New(xerr.Unavailable, "user service is not configured")) 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 fc35a438..27cf8066 100644 --- a/services/gateway-service/internal/transport/http/roomapi/room_view.go +++ b/services/gateway-service/internal/transport/http/roomapi/room_view.go @@ -53,6 +53,7 @@ type roomListItemData struct { RoomShortID string `json:"room_short_id,omitempty"` CountryCode string `json:"country_code,omitempty"` CountryFlag string `json:"country_flag,omitempty"` + RoomBorderURL string `json:"room_border_url"` RoomBorder *roomDecorationData `json:"room_border,omitempty"` RoomNameStyle *roomDecorationData `json:"room_name_style,omitempty"` } @@ -451,6 +452,7 @@ type roomInitialData struct { VisibleRegionID int64 `json:"visible_region_id,omitempty"` Version int64 `json:"version"` ActiveBackground *roomBackgroundData `json:"active_background,omitempty"` + RoomBorderURL string `json:"room_border_url"` RoomBorder *roomDecorationData `json:"room_border,omitempty"` RoomNameStyle *roomDecorationData `json:"room_name_style,omitempty"` } @@ -754,6 +756,7 @@ func roomListItemDataFromProto(room *roomv1.RoomListItem) roomListItemData { RoomShortID: room.GetRoomShortId(), CountryCode: room.GetCountryCode(), CountryFlag: roomProfileCountryFlag(room.GetCountryCode()), + RoomBorderURL: roomBorderURL(room.GetRoomBorderUrl(), room.GetRoomBorder()), RoomBorder: roomDecorationDataFromProto(room.GetRoomBorder()), RoomNameStyle: roomDecorationDataFromProto(room.GetRoomNameStyle()), } @@ -898,11 +901,20 @@ func roomInitialRoomDataFromSnapshot(snapshot *roomv1.RoomSnapshot, roomID strin VisibleRegionID: snapshot.GetVisibleRegionId(), Version: snapshot.GetVersion(), ActiveBackground: roomBackgroundDataPointerFromProto(snapshot.GetActiveBackground()), + RoomBorderURL: roomBorderURL(snapshot.GetRoomBorderUrl(), snapshot.GetRoomBorder()), RoomBorder: roomDecorationDataFromProto(snapshot.GetRoomBorder()), RoomNameStyle: roomDecorationDataFromProto(snapshot.GetRoomNameStyle()), } } +func roomBorderURL(flatURL string, resource *roomv1.RoomDecorationResource) string { + if flatURL != "" { + return flatURL + } + // 滚动发布期间旧 room-service 只有结构化 room_border;gateway 从同一资源快照派生 URL,不额外查询。 + return resource.GetAssetUrl() +} + func roomViewerDataFromProto(user *roomv1.RoomUser, fallbackUserID int64) roomViewerData { userID := fallbackUserID if user.GetUserId() > 0 { diff --git a/services/gateway-service/internal/transport/http/roomapi/room_vip_handler.go b/services/gateway-service/internal/transport/http/roomapi/room_vip_handler.go index 51225705..e7024129 100644 --- a/services/gateway-service/internal/transport/http/roomapi/room_vip_handler.go +++ b/services/gateway-service/internal/transport/http/roomapi/room_vip_handler.go @@ -35,6 +35,14 @@ type applyRoomDecorationData struct { ServerTimeMS int64 `json:"server_time_ms"` } +type saveRoomBorderData struct { + Result roomCommandResultData `json:"result"` + Room roomInitialData `json:"room"` + RoomBorderURL string `json:"room_border_url"` + Resource *roomDecorationData `json:"resource,omitempty"` + ServerTimeMS int64 `json:"server_time_ms"` +} + type sendRoomImageData struct { Result roomCommandResultData `json:"result"` Accepted bool `json:"accepted"` @@ -74,6 +82,37 @@ func (h *Handler) applyRoomDecoration(writer http.ResponseWriter, request *http. httpkit.Write(writer, request, applyRoomDecorationDataFromProto(resp), err) } +// saveRoomBorder 是 Flutter 保存房间边框的专用入口。 +// URL 必须来自 wallet 的 room_border 资源目录,客户端只提交资源身份,不能用任意 URL 绕过 entitlement/VIP 校验。 +func (h *Handler) saveRoomBorder(writer http.ResponseWriter, request *http.Request) { + if h.roomVIPClient == nil { + httpkit.WriteError(writer, request, http.StatusServiceUnavailable, httpkit.CodeUpstreamError, "room vip feature is unavailable") + return + } + roomID := strings.TrimSpace(request.PathValue("room_id")) + var body struct { + CommandID string `json:"command_id"` + ResourceID flexibleInt64 `json:"resource_id"` + EntitlementID string `json:"entitlement_id"` + } + if !httpkit.Decode(writer, request, &body) { + return + } + body.CommandID = strings.TrimSpace(body.CommandID) + body.EntitlementID = strings.TrimSpace(body.EntitlementID) + if !roomid.ValidStringID(roomID) || !giftlimits.ValidCommandID(body.CommandID) || int64(body.ResourceID) <= 0 { + httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument") + return + } + resp, err := h.roomVIPClient.ApplyRoomDecoration(request.Context(), &roomv1.ApplyRoomDecorationRequest{ + Meta: httpkit.RoomMeta(request, roomID, body.CommandID), + DecorationType: "room_border", + ResourceId: int64(body.ResourceID), + EntitlementId: body.EntitlementID, + }) + httpkit.Write(writer, request, saveRoomBorderDataFromProto(resp), err) +} + func (h *Handler) sendRoomImage(writer http.ResponseWriter, request *http.Request) { if h.roomVIPClient == nil || h.objectURLVerifier == nil { httpkit.WriteError(writer, request, http.StatusServiceUnavailable, httpkit.CodeUpstreamError, "room image message is unavailable") @@ -109,6 +148,20 @@ func applyRoomDecorationDataFromProto(resp *roomv1.ApplyRoomDecorationResponse) } } +func saveRoomBorderDataFromProto(resp *roomv1.ApplyRoomDecorationResponse) saveRoomBorderData { + if resp == nil { + return saveRoomBorderData{} + } + room := roomInitialRoomDataFromSnapshot(resp.GetRoom(), resp.GetRoom().GetRoomId()) + return saveRoomBorderData{ + Result: commandResultDataFromProto(resp.GetResult()), + Room: room, + RoomBorderURL: roomBorderURL(room.RoomBorderURL, resp.GetResource()), + Resource: roomDecorationDataFromProto(resp.GetResource()), + ServerTimeMS: resp.GetServerTimeMs(), + } +} + func sendRoomImageDataFromProto(resp *roomv1.SendRoomImageResponse) sendRoomImageData { if resp == nil { return sendRoomImageData{} diff --git a/services/room-service/internal/integration/tencent_im.go b/services/room-service/internal/integration/tencent_im.go index 76dd5606..de2c1549 100644 --- a/services/room-service/internal/integration/tencent_im.go +++ b/services/room-service/internal/integration/tencent_im.go @@ -272,6 +272,10 @@ func roomEventFromEnvelope(envelope *roomeventsv1.EventEnvelope) (tencentim.Room "stops": string(stops), "angle_degrees": fmt.Sprintf("%g", body.GetAngleDegrees()), "entitlement_id": body.GetEntitlementId(), "expires_at_ms": fmt.Sprintf("%d", body.GetExpiresAtMs()), } + if strings.EqualFold(strings.TrimSpace(body.GetDecorationType()), "room_border") { + // Flutter 的房间模型使用扁平字符串字段;结构化通用 asset_url 继续保留给旧装扮解析器。 + base.Attributes["room_border_url"] = body.GetAssetUrl() + } return base, true, nil case "RoomImageMessageSent": var body roomeventsv1.RoomImageMessageSent @@ -373,6 +377,7 @@ func roomEventFromEnvelope(envelope *roomeventsv1.EventEnvelope) (tencentim.Room "sender_avatar": body.GetSenderAvatar(), "sender_display_user_id": body.GetSenderDisplayUserId(), "sender_pretty_display_user_id": body.GetSenderPrettyDisplayUserId(), + "sender_vip_level": fmt.Sprintf("%d", body.GetSenderVipLevel()), "receiver_nickname": body.GetReceiverNickname(), "receiver_avatar": body.GetReceiverAvatar(), "receiver_display_user_id": body.GetReceiverDisplayUserId(), @@ -421,6 +426,7 @@ func roomEventFromEnvelope(envelope *roomeventsv1.EventEnvelope) (tencentim.Room "sender_avatar": body.GetSenderAvatar(), "sender_display_user_id": body.GetSenderDisplayUserId(), "sender_pretty_display_user_id": body.GetSenderPrettyDisplayUserId(), + "sender_vip_level": fmt.Sprintf("%d", body.GetSenderVipLevel()), } return base, true, nil case "RoomHeatChanged": diff --git a/services/room-service/internal/integration/tencent_im_test.go b/services/room-service/internal/integration/tencent_im_test.go index 855cac00..d110369d 100644 --- a/services/room-service/internal/integration/tencent_im_test.go +++ b/services/room-service/internal/integration/tencent_im_test.go @@ -102,6 +102,23 @@ func TestRoomBackgroundChangedPublishesDedicatedIMEvent(t *testing.T) { } } +func TestRoomBorderChangedCarriesFlatURLInIMAttributes(t *testing.T) { + record, err := outbox.Build("room-border-im", "RoomDecorationChanged", 9, time.Now(), &roomeventsv1.RoomDecorationChanged{ + ActorUserId: 1001, DecorationType: "room_border", ResourceId: 5201, ResourceType: "room_border", + AssetUrl: "https://cdn.example.com/room-border/gold.webp", + }) + if err != nil { + t.Fatalf("Build RoomDecorationChanged envelope failed: %v", err) + } + event, publish, err := roomEventFromEnvelope(record.Envelope) + if err != nil || !publish { + t.Fatalf("room border decoration must publish: publish=%t err=%v", publish, err) + } + if event.EventType != "room_decoration_changed" || event.Attributes["asset_url"] != "https://cdn.example.com/room-border/gold.webp" || event.Attributes["room_border_url"] != "https://cdn.example.com/room-border/gold.webp" { + t.Fatalf("room border IM must expose flat and generic URLs: %+v", event) + } +} + func TestRoomGiftSentCarriesTargetGiftValueInIMAttributes(t *testing.T) { record, err := outbox.Build("room-gift-value", "RoomGiftSent", 8, time.Now(), &roomeventsv1.RoomGiftSent{ SenderUserId: 1001, @@ -113,6 +130,7 @@ func TestRoomGiftSentCarriesTargetGiftValueInIMAttributes(t *testing.T) { SenderName: "Robot Sender", SenderAvatar: "https://cdn.example/sender.png", SenderDisplayUserId: "100001", + SenderVipLevel: 7, ReceiverNickname: "Robot Receiver", ReceiverAvatar: "https://cdn.example/receiver.png", ReceiverDisplayUserId: "100002", @@ -131,12 +149,23 @@ func TestRoomGiftSentCarriesTargetGiftValueInIMAttributes(t *testing.T) { if event.EventType != "room_gift_sent" || event.GiftValue != 117 || event.Attributes["target_gift_value"] != "367" { t.Fatalf("gift IM event target gift value mismatch: %+v", event) } - if event.Attributes["sender_name"] != "Robot Sender" || event.Attributes["sender_avatar"] != "https://cdn.example/sender.png" || event.Attributes["sender_display_user_id"] != "100001" { + if event.Attributes["sender_name"] != "Robot Sender" || event.Attributes["sender_avatar"] != "https://cdn.example/sender.png" || event.Attributes["sender_display_user_id"] != "100001" || event.Attributes["sender_vip_level"] != "7" { t.Fatalf("gift IM event sender display fields mismatch: %+v", event.Attributes) } if event.Attributes["receiver_nickname"] != "Robot Receiver" || event.Attributes["receiver_avatar"] != "https://cdn.example/receiver.png" || event.Attributes["receiver_display_user_id"] != "100002" { t.Fatalf("gift IM event receiver display fields mismatch: %+v", event.Attributes) } + payload, err := json.Marshal(event) + if err != nil { + t.Fatalf("marshal gift IM event failed: %v", err) + } + var payloadData map[string]any + if err := json.Unmarshal(payload, &payloadData); err != nil { + t.Fatalf("decode gift IM event failed: %v payload=%s", err, payload) + } + if payloadData["sender_vip_level"] != "7" { + t.Fatalf("gift IM JSON must flatten sender VIP for Flutter: %+v", payloadData) + } robotRecord, err := outbox.Build("room-robot-gift-value", "RoomGiftSent", 8, time.Now(), &roomeventsv1.RoomGiftSent{ SenderUserId: 2001, @@ -190,6 +219,7 @@ func TestRoomGiftBatchSentPublishesSingleBatchIMEvent(t *testing.T) { record, err := outbox.Build("room-gift-batch", "RoomGiftBatchSent", 12, time.Now(), &roomeventsv1.RoomGiftBatchSent{ SenderUserId: 1001, SenderName: "Batch Sender", + SenderVipLevel: 8, GiftId: "gift_clover", GiftCount: 2, TotalGiftValue: 600, @@ -239,6 +269,9 @@ func TestRoomGiftBatchSentPublishesSingleBatchIMEvent(t *testing.T) { if event.Attributes["gift_id"] != "gift_clover" || event.Attributes["gift_count"] != "2" || event.Attributes["command_id"] != "cmd-batch" { t.Fatalf("batch gift IM gift attributes mismatch: %+v", event.Attributes) } + if event.Attributes["sender_vip_level"] != "8" { + t.Fatalf("batch gift IM sender VIP mismatch: %+v", event.Attributes) + } if _, exists := event.Attributes["targets"]; exists { t.Fatalf("room batch IM must not include bulky targets: %+v", event.Attributes) } diff --git a/services/room-service/internal/room/command/command.go b/services/room-service/internal/room/command/command.go index d196c3e1..ee30be6d 100644 --- a/services/room-service/internal/room/command/command.go +++ b/services/room-service/internal/room/command/command.go @@ -617,6 +617,8 @@ type GiftDisplayProfile struct { DisplayUserID string `json:"display_user_id,omitempty"` // PrettyDisplayUserID 是当前靓号展示值,保留给后续客户端按展示策略选择。 PrettyDisplayUserID string `json:"pretty_display_user_id,omitempty"` + // EffectiveVIPLevel 固化送礼入口看到的生效等级,命令重放时不能重新查询导致同一礼物事件展示漂移。 + EffectiveVIPLevel int32 `json:"effective_vip_level,omitempty"` } // RocketRewardGrant 记录发射命令中已经结算出的资源组发放结果。 diff --git a/services/room-service/internal/room/service/gift_batch.go b/services/room-service/internal/room/service/gift_batch.go index 1a53b537..a4fb7cfc 100644 --- a/services/room-service/internal/room/service/gift_batch.go +++ b/services/room-service/internal/room/service/gift_batch.go @@ -43,6 +43,7 @@ func buildSendGiftBatchDisplay(cmd command.SendGift, billing *walletv1.DebitGift SenderAvatar: strings.TrimSpace(cmd.SenderDisplayProfile.Avatar), SenderDisplayUserId: strings.TrimSpace(cmd.SenderDisplayProfile.DisplayUserID), SenderPrettyDisplayUserId: strings.TrimSpace(cmd.SenderDisplayProfile.PrettyDisplayUserID), + SenderVipLevel: cmd.SenderDisplayProfile.EffectiveVIPLevel, GiftId: cmd.GiftID, GiftCount: cmd.GiftCount, TotalGiftValue: sumTargetGiftValue(targetBillings), @@ -91,6 +92,7 @@ func roomGiftBatchSentEventFromDisplay(display *roomv1.SendGiftBatchDisplay, vis SenderAvatar: display.GetSenderAvatar(), SenderDisplayUserId: display.GetSenderDisplayUserId(), SenderPrettyDisplayUserId: display.GetSenderPrettyDisplayUserId(), + SenderVipLevel: display.GetSenderVipLevel(), GiftId: display.GetGiftId(), GiftCount: display.GetGiftCount(), TotalGiftValue: display.GetTotalGiftValue(), diff --git a/services/room-service/internal/room/service/gift_events.go b/services/room-service/internal/room/service/gift_events.go index 704c4cc0..b1cdbc05 100644 --- a/services/room-service/internal/room/service/gift_events.go +++ b/services/room-service/internal/room/service/gift_events.go @@ -38,6 +38,7 @@ func buildRoomGiftSentRecords(roomID string, roomVersion int64, now time.Time, c SenderAvatar: strings.TrimSpace(cmd.SenderDisplayProfile.Avatar), SenderDisplayUserId: strings.TrimSpace(cmd.SenderDisplayProfile.DisplayUserID), SenderPrettyDisplayUserId: strings.TrimSpace(cmd.SenderDisplayProfile.PrettyDisplayUserID), + SenderVipLevel: cmd.SenderDisplayProfile.EffectiveVIPLevel, ReceiverNickname: giftDisplayName(targetDisplayProfile), ReceiverAvatar: strings.TrimSpace(targetDisplayProfile.Avatar), ReceiverDisplayUserId: strings.TrimSpace(targetDisplayProfile.DisplayUserID), diff --git a/services/room-service/internal/room/service/gift_events_test.go b/services/room-service/internal/room/service/gift_events_test.go index 90e2e9b7..0420bd80 100644 --- a/services/room-service/internal/room/service/gift_events_test.go +++ b/services/room-service/internal/room/service/gift_events_test.go @@ -35,6 +35,7 @@ func TestBuildRoomGiftSentRecordsPreservesDurableFactFields(t *testing.T) { Avatar: " avatar-sender ", DisplayUserID: "S101", PrettyDisplayUserID: "PS101", + EffectiveVIPLevel: 7, }, TargetDisplayProfiles: []command.GiftDisplayProfile{{ UserID: 202, @@ -99,7 +100,7 @@ func TestBuildRoomGiftSentRecordsPreservesDurableFactFields(t *testing.T) { if !event.GetIsRobotGift() || !event.GetSyntheticLuckyGift() || !event.GetRealRoomRobotGift() { t.Fatalf("robot/lucky flags mismatch: %+v", &event) } - if event.GetSenderName() != "sender" || event.GetReceiverNickname() != "receiver" || event.GetReceiverAvatar() != "avatar-receiver" { + if event.GetSenderName() != "sender" || event.GetSenderVipLevel() != 7 || event.GetReceiverNickname() != "receiver" || event.GetReceiverAvatar() != "avatar-receiver" { t.Fatalf("display profile fields mismatch: %+v", &event) } } @@ -168,6 +169,7 @@ func TestRoomGiftBatchSentEventFromDisplayPreservesTargetsAndTotals(t *testing.T Avatar: "sender-avatar", DisplayUserID: "S301", PrettyDisplayUserID: "PS301", + EffectiveVIPLevel: 8, }, TargetDisplayProfiles: []command.GiftDisplayProfile{ {UserID: 401, Username: "target-one", Avatar: "avatar-one", DisplayUserID: "T401", PrettyDisplayUserID: "PT401"}, @@ -202,7 +204,7 @@ func TestRoomGiftBatchSentEventFromDisplayPreservesTargetsAndTotals(t *testing.T display := buildSendGiftBatchDisplay(cmd, billing, targetBillings, map[int64]int64{401: 1001, 402: 1002}, luckyGifts, 8888) event := roomGiftBatchSentEventFromDisplay(display, 6006) - if event.GetSenderUserId() != 301 || event.GetGiftId() != "gift-star" || event.GetGiftCount() != 2 || event.GetCommandId() != "cmd-batch-fact" { + if event.GetSenderUserId() != 301 || event.GetSenderVipLevel() != 8 || display.GetSenderVipLevel() != 8 || event.GetGiftId() != "gift-star" || event.GetGiftCount() != 2 || event.GetCommandId() != "cmd-batch-fact" { t.Fatalf("batch root fields mismatch: %+v", event) } if event.GetTotalGiftValue() != 330 || event.GetTotalCoinSpent() != 220 || event.GetBillingReceiptId() != "receipt-batch-root" || event.GetRoomHeat() != 8888 { diff --git a/services/room-service/internal/room/service/gift_profile.go b/services/room-service/internal/room/service/gift_profile.go index 11dea3cc..c7e354cf 100644 --- a/services/room-service/internal/room/service/gift_profile.go +++ b/services/room-service/internal/room/service/gift_profile.go @@ -48,6 +48,11 @@ func giftDisplayProfileFromProto(item *roomv1.SendGiftDisplayProfile) command.Gi if item == nil || item.GetUserId() <= 0 { return command.GiftDisplayProfile{} } + effectiveVIPLevel := item.GetEffectiveVipLevel() + if effectiveVIPLevel < 0 { + // VIP 等级只用于展示;协议异常值归零,不能污染持久命令或让送礼主链路失败。 + effectiveVIPLevel = 0 + } // 展示快照只裁剪空白字符,不做业务兜底;兜底顺序统一放在 giftDisplayName,避免事件字段互相不一致。 return command.GiftDisplayProfile{ UserID: item.GetUserId(), @@ -55,6 +60,7 @@ func giftDisplayProfileFromProto(item *roomv1.SendGiftDisplayProfile) command.Gi Avatar: strings.TrimSpace(item.GetAvatar()), DisplayUserID: strings.TrimSpace(item.GetDisplayUserId()), PrettyDisplayUserID: strings.TrimSpace(item.GetPrettyDisplayUserId()), + EffectiveVIPLevel: effectiveVIPLevel, } } diff --git a/services/room-service/internal/room/service/list.go b/services/room-service/internal/room/service/list.go index 17317884..29672dc9 100644 --- a/services/room-service/internal/room/service/list.go +++ b/services/room-service/internal/room/service/list.go @@ -634,6 +634,7 @@ func encodeRoomListCursor(tab string, query string, countryCode string, viewerCo // roomListItemToProto 将 repository 投影转换为跨服务响应对象。 func roomListItemToProto(entry RoomListEntry) *roomv1.RoomListItem { nowMS := time.Now().UTC().UnixMilli() + roomBorder := unmarshalRoomListDecoration(entry.RoomBorderJSON, nowMS) ownerVIPLevel := entry.OwnerVIPLevel if ownerVIPLevel <= 0 || entry.OwnerVIPExpiresAtMS <= nowMS { // 到期判断留在服务端,避免自然到期后仍依赖 wallet 事件才能从列表撤下 VIP 展示。 @@ -655,7 +656,8 @@ func roomListItemToProto(entry RoomListEntry) *roomv1.RoomListItem { OccupiedSeatCount: entry.OccupiedSeatCount, VisibleRegionId: entry.VisibleRegionID, CountryCode: normalizeRoomCountryCode(entry.OwnerCountryCode), - RoomBorder: unmarshalRoomListDecoration(entry.RoomBorderJSON, nowMS), + RoomBorder: roomBorder, + RoomBorderUrl: roomBorder.GetAssetUrl(), RoomNameStyle: unmarshalRoomListDecoration(entry.RoomNameStyleJSON, nowMS), OwnerVipLevel: ownerVIPLevel, } diff --git a/services/room-service/internal/room/service/list_cache_service_test.go b/services/room-service/internal/room/service/list_cache_service_test.go index 6c758aad..1e2abab3 100644 --- a/services/room-service/internal/room/service/list_cache_service_test.go +++ b/services/room-service/internal/room/service/list_cache_service_test.go @@ -28,6 +28,7 @@ func TestListRoomsUsesCacheWhenEligible(t *testing.T) { Mode: "voice", Status: "active", SortScore: 99, + RoomBorderJSON: `{"resource_id":5201,"resource_type":"room_border","asset_url":"https://cdn.example.com/room-border/gold.webp","expires_at_ms":4102444800000}`, }}} svc := roomservice.New(roomservice.Config{ NodeID: "node-list-cache-hit-test", @@ -56,6 +57,9 @@ func TestListRoomsUsesCacheWhenEligible(t *testing.T) { if got := resp.GetRooms()[0].GetOwnerVipLevel(); got != 5 { t.Fatalf("list response must expose the cached owner VIP without a wallet lookup, got=%d", got) } + if got := resp.GetRooms()[0].GetRoomBorderUrl(); got != "https://cdn.example.com/room-border/gold.webp" || resp.GetRooms()[0].GetRoomBorder().GetAssetUrl() != got { + t.Fatalf("list response must derive flat room border URL from the existing projection: %+v", resp.GetRooms()[0]) + } } func TestListRoomsFallsBackToMySQLWhenCacheMisses(t *testing.T) { diff --git a/services/room-service/internal/room/service/recovery_test.go b/services/room-service/internal/room/service/recovery_test.go index 63b6c4de..4fc49b80 100644 --- a/services/room-service/internal/room/service/recovery_test.go +++ b/services/room-service/internal/room/service/recovery_test.go @@ -2,11 +2,28 @@ package service import ( "testing" + "time" "hyapp/services/room-service/internal/room/command" "hyapp/services/room-service/internal/room/state" ) +func TestRoomStateSnapshotDerivesActiveRoomBorderURL(t *testing.T) { + now := time.Date(2026, 7, 20, 8, 0, 0, 0, time.UTC) + current := state.NewRoomState("room-border-snapshot", 7001, 8, "voice") + current.RoomBorder = &state.RoomDecoration{ + ResourceID: 5201, ResourceType: "room_border", AssetURL: "https://cdn.example.com/room-border/gold.webp", ExpiresAtMS: now.Add(time.Hour).UnixMilli(), + } + active := current.ToProtoAt(now.UnixMilli()) + if active.GetRoomBorderUrl() != "https://cdn.example.com/room-border/gold.webp" || active.GetRoomBorder().GetAssetUrl() != active.GetRoomBorderUrl() { + t.Fatalf("active room border snapshot must expose the same flat URL: %+v", active) + } + expired := current.ToProtoAt(now.Add(2 * time.Hour).UnixMilli()) + if expired.GetRoomBorderUrl() != "" || expired.GetRoomBorder() != nil { + t.Fatalf("expired room border must disappear from both flat and structured fields: %+v", expired) + } +} + func TestReplayMicHeartbeatSkipsStaleMissingSeat(t *testing.T) { current := state.NewRoomState("room-replay-stale-heartbeat", 7101, 8, "voice") current.OnlineUsers[7101] = &state.RoomUserState{UserID: 7101, Role: "owner", JoinedAtMS: 1000, LastSeenAtMS: 1000} diff --git a/services/room-service/internal/room/state/state.go b/services/room-service/internal/room/state/state.go index 089e37bd..eb2b8254 100644 --- a/services/room-service/internal/room/state/state.go +++ b/services/room-service/internal/room/state/state.go @@ -521,6 +521,7 @@ func (s *RoomState) ToProtoAt(nowMS int64) *roomv1.RoomSnapshot { }) } + roomBorder := roomDecorationToProtoAt(s.RoomBorder, nowMS) return &roomv1.RoomSnapshot{ RoomId: s.RoomID, OwnerUserId: s.OwnerUserID, @@ -542,7 +543,8 @@ func (s *RoomState) ToProtoAt(nowMS int64) *roomv1.RoomSnapshot { Rocket: rocketStateToProto(s.Rocket), BanStates: sortedModerationStates(s.BanUsers), OnlineCount: int32(len(users)), - RoomBorder: roomDecorationToProtoAt(s.RoomBorder, nowMS), + RoomBorder: roomBorder, + RoomBorderUrl: roomBorder.GetAssetUrl(), RoomNameStyle: roomDecorationToProtoAt(s.RoomNameStyle, nowMS), ActiveBackground: roomBackgroundToProto(s.ActiveBackground), }