vip相关接口
This commit is contained in:
parent
35ca1ce071
commit
5e124361f8
@ -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" +
|
||||
|
||||
@ -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 事实派生的真人中奖展示事件。
|
||||
|
||||
@ -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" +
|
||||
|
||||
@ -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。
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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。
|
||||
|
||||
310
docs/flutter对接/房间列表房主VIP等级Flutter对接.md
Normal file
310
docs/flutter对接/房间列表房主VIP等级Flutter对接.md
Normal file
@ -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 <access_token>
|
||||
```
|
||||
|
||||
房间卡片字段位于:
|
||||
|
||||
```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<String, dynamic> 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<String, dynamic>?)?['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 <access_token>
|
||||
```
|
||||
|
||||
然后由房主保存到指定房间:
|
||||
|
||||
```http
|
||||
POST /api/v1/rooms/{room_id}/border/save
|
||||
Authorization: Bearer <access_token>
|
||||
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<List<RoomListItem>> fetchRooms({
|
||||
String tab = 'hot',
|
||||
String? cursor,
|
||||
}) async {
|
||||
final response = await dio.get<Map<String, dynamic>>(
|
||||
'/api/v1/rooms',
|
||||
queryParameters: <String, dynamic>{
|
||||
'tab': tab,
|
||||
'limit': 20,
|
||||
if (cursor != null && cursor.isNotEmpty) 'cursor': cursor,
|
||||
},
|
||||
);
|
||||
|
||||
final envelope = response.data ?? const <String, dynamic>{};
|
||||
if (envelope['code'] != 'OK') {
|
||||
throw StateError(envelope['message']?.toString() ?? 'room list failed');
|
||||
}
|
||||
|
||||
final data = envelope['data'] as Map<String, dynamic>? ??
|
||||
const <String, dynamic>{};
|
||||
final rooms = data['rooms'] as List<dynamic>? ?? const <dynamic>[];
|
||||
return rooms
|
||||
.whereType<Map<String, dynamic>>()
|
||||
.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。
|
||||
@ -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<String, dynamic> 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:
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -633,6 +633,9 @@ definitions:
|
||||
version:
|
||||
type: integer
|
||||
format: int64
|
||||
room_border_url:
|
||||
type: string
|
||||
description: 当前有效 room_border.asset_url 的扁平字段;空字符串表示没有有效边框。
|
||||
RoomBanState:
|
||||
type: object
|
||||
properties:
|
||||
|
||||
@ -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` | 离开房间 |
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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})
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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"))
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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{}
|
||||
|
||||
@ -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":
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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 记录发射命令中已经结算出的资源组发放结果。
|
||||
|
||||
@ -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(),
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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,
|
||||
}
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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),
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user