vip相关接口

This commit is contained in:
zhx 2026-07-20 21:37:27 +08:00
parent 35ca1ce071
commit 5e124361f8
28 changed files with 830 additions and 43 deletions

View File

@ -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" +

View File

@ -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

View File

@ -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" +

View File

@ -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

View File

@ -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

View File

@ -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。

View 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。

View File

@ -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

View File

@ -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:

View File

@ -633,6 +633,9 @@ definitions:
version:
type: integer
format: int64
room_border_url:
type: string
description: 当前有效 room_border.asset_url 的扁平字段;空字符串表示没有有效边框。
RoomBanState:
type: object
properties:

View File

@ -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` | 离开房间 |

View File

@ -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)

View File

@ -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})

View File

@ -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,

View File

@ -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"))

View File

@ -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_bordergateway 从同一资源快照派生 URL不额外查询。
return resource.GetAssetUrl()
}
func roomViewerDataFromProto(user *roomv1.RoomUser, fallbackUserID int64) roomViewerData {
userID := fallbackUserID
if user.GetUserId() > 0 {

View File

@ -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{}

View File

@ -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":

View File

@ -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)
}

View File

@ -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 记录发射命令中已经结算出的资源组发放结果。

View File

@ -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(),

View File

@ -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),

View File

@ -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 {

View File

@ -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,
}
}

View File

@ -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,
}

View File

@ -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) {

View File

@ -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}

View File

@ -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),
}