This commit is contained in:
zhx 2026-07-23 13:33:51 +08:00
parent 70d1912054
commit 93395f01d0
51 changed files with 433 additions and 93 deletions

View File

@ -145,7 +145,9 @@ type LuckyGiftDrawn struct {
DrawCreatedAtMs int64 `protobuf:"varint,26,opt,name=draw_created_at_ms,json=drawCreatedAtMs,proto3" json:"draw_created_at_ms,omitempty"` DrawCreatedAtMs int64 `protobuf:"varint,26,opt,name=draw_created_at_ms,json=drawCreatedAtMs,proto3" json:"draw_created_at_ms,omitempty"`
RewardGrantedAtMs int64 `protobuf:"varint,27,opt,name=reward_granted_at_ms,json=rewardGrantedAtMs,proto3" json:"reward_granted_at_ms,omitempty"` RewardGrantedAtMs int64 `protobuf:"varint,27,opt,name=reward_granted_at_ms,json=rewardGrantedAtMs,proto3" json:"reward_granted_at_ms,omitempty"`
// room_locked 是 room-service 在原始送礼命令内固化的 Room Cell 锁状态;下游不能异步回查当前房间再改写历史事实。 // room_locked 是 room-service 在原始送礼命令内固化的 Room Cell 锁状态;下游不能异步回查当前房间再改写历史事实。
RoomLocked bool `protobuf:"varint,28,opt,name=room_locked,json=roomLocked,proto3" json:"room_locked,omitempty"` RoomLocked bool `protobuf:"varint,28,opt,name=room_locked,json=roomLocked,proto3" json:"room_locked,omitempty"`
// owner_user_id 是原始送礼命令内固化的房主快照,只用于跨房展示交互,不参与抽奖或返奖。
OwnerUserId int64 `protobuf:"varint,29,opt,name=owner_user_id,json=ownerUserId,proto3" json:"owner_user_id,omitempty"`
unknownFields protoimpl.UnknownFields unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache sizeCache protoimpl.SizeCache
} }
@ -376,6 +378,13 @@ func (x *LuckyGiftDrawn) GetRoomLocked() bool {
return false return false
} }
func (x *LuckyGiftDrawn) GetOwnerUserId() int64 {
if x != nil {
return x.OwnerUserId
}
return 0
}
var File_proto_events_luckygift_v1_events_proto protoreflect.FileDescriptor var File_proto_events_luckygift_v1_events_proto protoreflect.FileDescriptor
const file_proto_events_luckygift_v1_events_proto_rawDesc = "" + const file_proto_events_luckygift_v1_events_proto_rawDesc = "" +
@ -388,7 +397,7 @@ const file_proto_events_luckygift_v1_events_proto_rawDesc = "" +
"\bapp_code\x18\x03 \x01(\tR\aappCode\x12\x17\n" + "\bapp_code\x18\x03 \x01(\tR\aappCode\x12\x17\n" +
"\adraw_id\x18\x04 \x01(\tR\x06drawId\x12$\n" + "\adraw_id\x18\x04 \x01(\tR\x06drawId\x12$\n" +
"\x0eoccurred_at_ms\x18\x05 \x01(\x03R\foccurredAtMs\x12\x12\n" + "\x0eoccurred_at_ms\x18\x05 \x01(\x03R\foccurredAtMs\x12\x12\n" +
"\x04body\x18\x06 \x01(\fR\x04body\"\xcc\b\n" + "\x04body\x18\x06 \x01(\fR\x04body\"\xf0\b\n" +
"\x0eLuckyGiftDrawn\x12\x17\n" + "\x0eLuckyGiftDrawn\x12\x17\n" +
"\adraw_id\x18\x01 \x01(\tR\x06drawId\x12\x1d\n" + "\adraw_id\x18\x01 \x01(\tR\x06drawId\x12\x1d\n" +
"\n" + "\n" +
@ -424,7 +433,8 @@ const file_proto_events_luckygift_v1_events_proto_rawDesc = "" +
"\x12draw_created_at_ms\x18\x1a \x01(\x03R\x0fdrawCreatedAtMs\x12/\n" + "\x12draw_created_at_ms\x18\x1a \x01(\x03R\x0fdrawCreatedAtMs\x12/\n" +
"\x14reward_granted_at_ms\x18\x1b \x01(\x03R\x11rewardGrantedAtMs\x12\x1f\n" + "\x14reward_granted_at_ms\x18\x1b \x01(\x03R\x11rewardGrantedAtMs\x12\x1f\n" +
"\vroom_locked\x18\x1c \x01(\bR\n" + "\vroom_locked\x18\x1c \x01(\bR\n" +
"roomLockedB=Z;hyapp.local/api/proto/events/luckygift/v1;luckygifteventsv1b\x06proto3" "roomLocked\x12\"\n" +
"\rowner_user_id\x18\x1d \x01(\x03R\vownerUserIdB=Z;hyapp.local/api/proto/events/luckygift/v1;luckygifteventsv1b\x06proto3"
var ( var (
file_proto_events_luckygift_v1_events_proto_rawDescOnce sync.Once file_proto_events_luckygift_v1_events_proto_rawDescOnce sync.Once

View File

@ -53,4 +53,6 @@ message LuckyGiftDrawn {
int64 reward_granted_at_ms = 27; int64 reward_granted_at_ms = 27;
// room_locked room-service Room Cell // room_locked room-service Room Cell
bool room_locked = 28; bool room_locked = 28;
// owner_user_id
int64 owner_user_id = 29;
} }

View File

@ -1725,8 +1725,10 @@ type RoomGiftSent struct {
SenderVipLevel int32 `protobuf:"varint,33,opt,name=sender_vip_level,json=senderVipLevel,proto3" json:"sender_vip_level,omitempty"` SenderVipLevel int32 `protobuf:"varint,33,opt,name=sender_vip_level,json=senderVipLevel,proto3" json:"sender_vip_level,omitempty"`
// sender_gift_broadcast_animation_url 是送礼入口从有效 gift_tray_skin 权益固化的动效 URL下游禁止异步回查当前 VIP 覆盖历史事实。 // sender_gift_broadcast_animation_url 是送礼入口从有效 gift_tray_skin 权益固化的动效 URL下游禁止异步回查当前 VIP 覆盖历史事实。
SenderGiftBroadcastAnimationUrl string `protobuf:"bytes,34,opt,name=sender_gift_broadcast_animation_url,json=senderGiftBroadcastAnimationUrl,proto3" json:"sender_gift_broadcast_animation_url,omitempty"` SenderGiftBroadcastAnimationUrl string `protobuf:"bytes,34,opt,name=sender_gift_broadcast_animation_url,json=senderGiftBroadcastAnimationUrl,proto3" json:"sender_gift_broadcast_animation_url,omitempty"`
unknownFields protoimpl.UnknownFields // owner_user_id 固化送礼命令执行时的 Room Cell 房主跨房飘屏只能用它优化密码框JoinRoom 仍做最终权限校验。
sizeCache protoimpl.SizeCache OwnerUserId int64 `protobuf:"varint,35,opt,name=owner_user_id,json=ownerUserId,proto3" json:"owner_user_id,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
} }
func (x *RoomGiftSent) Reset() { func (x *RoomGiftSent) Reset() {
@ -1997,6 +1999,13 @@ func (x *RoomGiftSent) GetSenderGiftBroadcastAnimationUrl() string {
return "" return ""
} }
func (x *RoomGiftSent) GetOwnerUserId() int64 {
if x != nil {
return x.OwnerUserId
}
return 0
}
// RoomGiftBatchLuckyResult 是批量送礼展示事件内的逐目标幸运礼物结果快照。 // RoomGiftBatchLuckyResult 是批量送礼展示事件内的逐目标幸运礼物结果快照。
// 它只进入房间 IM 展示,不作为 activity 中奖事实,也不携带送礼人余额。 // 它只进入房间 IM 展示,不作为 activity 中奖事实,也不携带送礼人余额。
type RoomGiftBatchLuckyResult struct { type RoomGiftBatchLuckyResult struct {
@ -2553,7 +2562,9 @@ type RoomLuckyGiftDrawn struct {
DrawCreatedAtMs int64 `protobuf:"varint,25,opt,name=draw_created_at_ms,json=drawCreatedAtMs,proto3" json:"draw_created_at_ms,omitempty"` DrawCreatedAtMs int64 `protobuf:"varint,25,opt,name=draw_created_at_ms,json=drawCreatedAtMs,proto3" json:"draw_created_at_ms,omitempty"`
RewardGrantedAtMs int64 `protobuf:"varint,26,opt,name=reward_granted_at_ms,json=rewardGrantedAtMs,proto3" json:"reward_granted_at_ms,omitempty"` RewardGrantedAtMs int64 `protobuf:"varint,26,opt,name=reward_granted_at_ms,json=rewardGrantedAtMs,proto3" json:"reward_granted_at_ms,omitempty"`
// room_locked 来自 lucky owner 事实中的送礼时房间快照,仅用于客户端点击飘屏前决定是否先收集密码。 // room_locked 来自 lucky owner 事实中的送礼时房间快照,仅用于客户端点击飘屏前决定是否先收集密码。
RoomLocked bool `protobuf:"varint,27,opt,name=room_locked,json=roomLocked,proto3" json:"room_locked,omitempty"` RoomLocked bool `protobuf:"varint,27,opt,name=room_locked,json=roomLocked,proto3" json:"room_locked,omitempty"`
// owner_user_id 与 room_locked 来自同一次原始送礼快照,禁止用中奖人、送礼人或异步房间查询替代。
OwnerUserId int64 `protobuf:"varint,28,opt,name=owner_user_id,json=ownerUserId,proto3" json:"owner_user_id,omitempty"`
unknownFields protoimpl.UnknownFields unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache sizeCache protoimpl.SizeCache
} }
@ -2777,6 +2788,13 @@ func (x *RoomLuckyGiftDrawn) GetRoomLocked() bool {
return false return false
} }
func (x *RoomLuckyGiftDrawn) GetOwnerUserId() int64 {
if x != nil {
return x.OwnerUserId
}
return 0
}
// RoomRobotLuckyGiftDrawn 是机器人房间专用幸运礼物展示事件。 // RoomRobotLuckyGiftDrawn 是机器人房间专用幸运礼物展示事件。
// 它复用客户端 lucky_gift_drawn 表现协议,但不代表真实奖池抽奖和钱包返奖事实。 // 它复用客户端 lucky_gift_drawn 表现协议,但不代表真实奖池抽奖和钱包返奖事实。
type RoomRobotLuckyGiftDrawn struct { type RoomRobotLuckyGiftDrawn struct {
@ -3308,7 +3326,9 @@ type RoomRocketIgnited struct {
RocketCoverUrl string `protobuf:"bytes,13,opt,name=rocket_cover_url,json=rocketCoverUrl,proto3" json:"rocket_cover_url,omitempty"` RocketCoverUrl string `protobuf:"bytes,13,opt,name=rocket_cover_url,json=rocketCoverUrl,proto3" json:"rocket_cover_url,omitempty"`
ResetAtMs int64 `protobuf:"varint,14,opt,name=reset_at_ms,json=resetAtMs,proto3" json:"reset_at_ms,omitempty"` ResetAtMs int64 `protobuf:"varint,14,opt,name=reset_at_ms,json=resetAtMs,proto3" json:"reset_at_ms,omitempty"`
// room_locked 固化点火礼物在 Room Cell 串行执行时的锁房状态,跨房点击不能异步回查后覆盖该次展示事实。 // room_locked 固化点火礼物在 Room Cell 串行执行时的锁房状态,跨房点击不能异步回查后覆盖该次展示事实。
RoomLocked bool `protobuf:"varint,15,opt,name=room_locked,json=roomLocked,proto3" json:"room_locked,omitempty"` RoomLocked bool `protobuf:"varint,15,opt,name=room_locked,json=roomLocked,proto3" json:"room_locked,omitempty"`
// owner_user_id 固化点火瞬间的 Room Cell 房主,火箭贡献者和点火人都不能替代该身份。
OwnerUserId int64 `protobuf:"varint,16,opt,name=owner_user_id,json=ownerUserId,proto3" json:"owner_user_id,omitempty"`
unknownFields protoimpl.UnknownFields unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache sizeCache protoimpl.SizeCache
} }
@ -3448,6 +3468,13 @@ func (x *RoomRocketIgnited) GetRoomLocked() bool {
return false return false
} }
func (x *RoomRocketIgnited) GetOwnerUserId() int64 {
if x != nil {
return x.OwnerUserId
}
return 0
}
// RoomRocketLaunched 表达火箭已经发射,在线用户名单按发射瞬间 Room Cell presence 结算。 // RoomRocketLaunched 表达火箭已经发射,在线用户名单按发射瞬间 Room Cell presence 结算。
type RoomRocketLaunched struct { type RoomRocketLaunched struct {
state protoimpl.MessageState `protogen:"open.v1"` state protoimpl.MessageState `protogen:"open.v1"`
@ -3783,7 +3810,7 @@ const file_proto_events_room_v1_events_proto_rawDesc = "" +
"\x0etarget_user_id\x18\x02 \x01(\x03R\ftargetUserId\"\\\n" + "\x0etarget_user_id\x18\x02 \x01(\x03R\ftargetUserId\"\\\n" +
"\x10RoomUserUnbanned\x12\"\n" + "\x10RoomUserUnbanned\x12\"\n" +
"\ractor_user_id\x18\x01 \x01(\x03R\vactorUserId\x12$\n" + "\ractor_user_id\x18\x01 \x01(\x03R\vactorUserId\x12$\n" +
"\x0etarget_user_id\x18\x02 \x01(\x03R\ftargetUserId\"\x8a\v\n" + "\x0etarget_user_id\x18\x02 \x01(\x03R\ftargetUserId\"\xae\v\n" +
"\fRoomGiftSent\x12$\n" + "\fRoomGiftSent\x12$\n" +
"\x0esender_user_id\x18\x01 \x01(\x03R\fsenderUserId\x12$\n" + "\x0esender_user_id\x18\x01 \x01(\x03R\fsenderUserId\x12$\n" +
"\x0etarget_user_id\x18\x02 \x01(\x03R\ftargetUserId\x12\x17\n" + "\x0etarget_user_id\x18\x02 \x01(\x03R\ftargetUserId\x12\x17\n" +
@ -3826,7 +3853,8 @@ const file_proto_events_room_v1_events_proto_rawDesc = "" +
"\vroom_locked\x18 \x01(\bR\n" + "\vroom_locked\x18 \x01(\bR\n" +
"roomLocked\x12(\n" + "roomLocked\x12(\n" +
"\x10sender_vip_level\x18! \x01(\x05R\x0esenderVipLevel\x12L\n" + "\x10sender_vip_level\x18! \x01(\x05R\x0esenderVipLevel\x12L\n" +
"#sender_gift_broadcast_animation_url\x18\" \x01(\tR\x1fsenderGiftBroadcastAnimationUrl\"\xdc\x04\n" + "#sender_gift_broadcast_animation_url\x18\" \x01(\tR\x1fsenderGiftBroadcastAnimationUrl\x12\"\n" +
"\rowner_user_id\x18# \x01(\x03R\vownerUserId\"\xdc\x04\n" +
"\x18RoomGiftBatchLuckyResult\x12\x18\n" + "\x18RoomGiftBatchLuckyResult\x12\x18\n" +
"\aenabled\x18\x01 \x01(\bR\aenabled\x12\x17\n" + "\aenabled\x18\x01 \x01(\bR\aenabled\x12\x17\n" +
"\adraw_id\x18\x02 \x01(\tR\x06drawId\x12\x1d\n" + "\adraw_id\x18\x02 \x01(\tR\x06drawId\x12\x1d\n" +
@ -3891,7 +3919,7 @@ const file_proto_events_room_v1_events_proto_rawDesc = "" +
"command_id\x18\x15 \x01(\tR\tcommandId\x12*\n" + "command_id\x18\x15 \x01(\tR\tcommandId\x12*\n" +
"\x11visible_region_id\x18\x16 \x01(\x03R\x0fvisibleRegionId\x12(\n" + "\x11visible_region_id\x18\x16 \x01(\x03R\x0fvisibleRegionId\x12(\n" +
"\x10sender_vip_level\x18\x17 \x01(\x05R\x0esenderVipLevel\x12L\n" + "\x10sender_vip_level\x18\x17 \x01(\x05R\x0esenderVipLevel\x12L\n" +
"#sender_gift_broadcast_animation_url\x18\x18 \x01(\tR\x1fsenderGiftBroadcastAnimationUrl\"\xb7\b\n" + "#sender_gift_broadcast_animation_url\x18\x18 \x01(\tR\x1fsenderGiftBroadcastAnimationUrl\"\xdb\b\n" +
"\x12RoomLuckyGiftDrawn\x12\x17\n" + "\x12RoomLuckyGiftDrawn\x12\x17\n" +
"\adraw_id\x18\x01 \x01(\tR\x06drawId\x12\x1d\n" + "\adraw_id\x18\x01 \x01(\tR\x06drawId\x12\x1d\n" +
"\n" + "\n" +
@ -3926,7 +3954,8 @@ const file_proto_events_room_v1_events_proto_rawDesc = "" +
"\x12draw_created_at_ms\x18\x19 \x01(\x03R\x0fdrawCreatedAtMs\x12/\n" + "\x12draw_created_at_ms\x18\x19 \x01(\x03R\x0fdrawCreatedAtMs\x12/\n" +
"\x14reward_granted_at_ms\x18\x1a \x01(\x03R\x11rewardGrantedAtMs\x12\x1f\n" + "\x14reward_granted_at_ms\x18\x1a \x01(\x03R\x11rewardGrantedAtMs\x12\x1f\n" +
"\vroom_locked\x18\x1b \x01(\bR\n" + "\vroom_locked\x18\x1b \x01(\bR\n" +
"roomLocked\"\x80\x04\n" + "roomLocked\x12\"\n" +
"\rowner_user_id\x18\x1c \x01(\x03R\vownerUserId\"\x80\x04\n" +
"\x17RoomRobotLuckyGiftDrawn\x12\x17\n" + "\x17RoomRobotLuckyGiftDrawn\x12\x17\n" +
"\adraw_id\x18\x01 \x01(\tR\x06drawId\x12\x1d\n" + "\adraw_id\x18\x01 \x01(\tR\x06drawId\x12\x1d\n" +
"\n" + "\n" +
@ -3981,7 +4010,7 @@ const file_proto_events_room_v1_events_proto_rawDesc = "" +
"gift_count\x18\f \x01(\x05R\tgiftCount\x12\x1d\n" + "gift_count\x18\f \x01(\x05R\tgiftCount\x12\x1d\n" +
"\n" + "\n" +
"command_id\x18\r \x01(\tR\tcommandId\x12*\n" + "command_id\x18\r \x01(\tR\tcommandId\x12*\n" +
"\x11visible_region_id\x18\x0e \x01(\x03R\x0fvisibleRegionId\"\xa3\x04\n" + "\x11visible_region_id\x18\x0e \x01(\x03R\x0fvisibleRegionId\"\xc7\x04\n" +
"\x11RoomRocketIgnited\x12\x1b\n" + "\x11RoomRocketIgnited\x12\x1b\n" +
"\trocket_id\x18\x01 \x01(\tR\brocketId\x12\x14\n" + "\trocket_id\x18\x01 \x01(\tR\brocketId\x12\x14\n" +
"\x05level\x18\x02 \x01(\x05R\x05level\x12!\n" + "\x05level\x18\x02 \x01(\x05R\x05level\x12!\n" +
@ -4002,7 +4031,8 @@ const file_proto_events_room_v1_events_proto_rawDesc = "" +
"\x10rocket_cover_url\x18\r \x01(\tR\x0erocketCoverUrl\x12\x1e\n" + "\x10rocket_cover_url\x18\r \x01(\tR\x0erocketCoverUrl\x12\x1e\n" +
"\vreset_at_ms\x18\x0e \x01(\x03R\tresetAtMs\x12\x1f\n" + "\vreset_at_ms\x18\x0e \x01(\x03R\tresetAtMs\x12\x1f\n" +
"\vroom_locked\x18\x0f \x01(\bR\n" + "\vroom_locked\x18\x0f \x01(\bR\n" +
"roomLocked\"\xe6\x02\n" + "roomLocked\x12\"\n" +
"\rowner_user_id\x18\x10 \x01(\x03R\vownerUserId\"\xe6\x02\n" +
"\x12RoomRocketLaunched\x12\x1b\n" + "\x12RoomRocketLaunched\x12\x1b\n" +
"\trocket_id\x18\x01 \x01(\tR\brocketId\x12\x14\n" + "\trocket_id\x18\x01 \x01(\tR\brocketId\x12\x14\n" +
"\x05level\x18\x02 \x01(\x05R\x05level\x12\x1d\n" + "\x05level\x18\x02 \x01(\x05R\x05level\x12\x1d\n" +

View File

@ -254,6 +254,8 @@ message RoomGiftSent {
int32 sender_vip_level = 33; int32 sender_vip_level = 33;
// sender_gift_broadcast_animation_url gift_tray_skin URL VIP // sender_gift_broadcast_animation_url gift_tray_skin URL VIP
string sender_gift_broadcast_animation_url = 34; string sender_gift_broadcast_animation_url = 34;
// owner_user_id Room Cell JoinRoom
int64 owner_user_id = 35;
} }
// RoomGiftBatchLuckyResult // RoomGiftBatchLuckyResult
@ -356,6 +358,8 @@ message RoomLuckyGiftDrawn {
int64 reward_granted_at_ms = 26; int64 reward_granted_at_ms = 26;
// room_locked lucky owner // room_locked lucky owner
bool room_locked = 27; bool room_locked = 27;
// owner_user_id room_locked
int64 owner_user_id = 28;
} }
// RoomRobotLuckyGiftDrawn // RoomRobotLuckyGiftDrawn
@ -437,6 +441,8 @@ message RoomRocketIgnited {
int64 reset_at_ms = 14; int64 reset_at_ms = 14;
// room_locked Room Cell // room_locked Room Cell
bool room_locked = 15; bool room_locked = 15;
// owner_user_id Room Cell
int64 owner_user_id = 16;
} }
// RoomRocketLaunched 线 Room Cell presence // RoomRocketLaunched 线 Room Cell presence

View File

@ -134,7 +134,9 @@ type LuckyGiftMeta struct {
// dynamic_v3 只用它判断闭窗时账号是否已满 48 小时;缺失时普通开奖继续,但补偿大奖资格 fail-close。 // dynamic_v3 只用它判断闭窗时账号是否已满 48 小时;缺失时普通开奖继续,但补偿大奖资格 fail-close。
UserRegisteredAtMs int64 `protobuf:"varint,23,opt,name=user_registered_at_ms,json=userRegisteredAtMs,proto3" json:"user_registered_at_ms,omitempty"` UserRegisteredAtMs int64 `protobuf:"varint,23,opt,name=user_registered_at_ms,json=userRegisteredAtMs,proto3" json:"user_registered_at_ms,omitempty"`
// room_locked 由 Room Cell 在送礼命令串行执行期间读取,只进入中奖展示事实,不参与抽奖或账务计算。 // room_locked 由 Room Cell 在送礼命令串行执行期间读取,只进入中奖展示事实,不参与抽奖或账务计算。
RoomLocked bool `protobuf:"varint,24,opt,name=room_locked,json=roomLocked,proto3" json:"room_locked,omitempty"` RoomLocked bool `protobuf:"varint,24,opt,name=room_locked,json=roomLocked,proto3" json:"room_locked,omitempty"`
// owner_user_id 与 room_locked 在同一 Room Cell 串行区间固化lucky owner 只持久化并透传该展示快照。
OwnerUserId int64 `protobuf:"varint,25,opt,name=owner_user_id,json=ownerUserId,proto3" json:"owner_user_id,omitempty"`
unknownFields protoimpl.UnknownFields unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache sizeCache protoimpl.SizeCache
} }
@ -337,6 +339,13 @@ func (x *LuckyGiftMeta) GetRoomLocked() bool {
return false return false
} }
func (x *LuckyGiftMeta) GetOwnerUserId() int64 {
if x != nil {
return x.OwnerUserId
}
return 0
}
type LuckyGiftRuleTier struct { type LuckyGiftRuleTier struct {
state protoimpl.MessageState `protogen:"open.v1"` state protoimpl.MessageState `protogen:"open.v1"`
Stage string `protobuf:"bytes,1,opt,name=stage,proto3" json:"stage,omitempty"` Stage string `protobuf:"bytes,1,opt,name=stage,proto3" json:"stage,omitempty"`
@ -5646,7 +5655,7 @@ const file_proto_luckygift_v1_luckygift_proto_rawDesc = "" +
"\x0fgateway_node_id\x18\x03 \x01(\tR\rgatewayNodeId\x12\x1c\n" + "\x0fgateway_node_id\x18\x03 \x01(\tR\rgatewayNodeId\x12\x1c\n" +
"\n" + "\n" +
"sent_at_ms\x18\x04 \x01(\x03R\bsentAtMs\x12\x19\n" + "sent_at_ms\x18\x04 \x01(\x03R\bsentAtMs\x12\x19\n" +
"\bapp_code\x18\x05 \x01(\tR\aappCode\"\x96\a\n" + "\bapp_code\x18\x05 \x01(\tR\aappCode\"\xba\a\n" +
"\rLuckyGiftMeta\x123\n" + "\rLuckyGiftMeta\x123\n" +
"\x04meta\x18\x01 \x01(\v2\x1f.hyapp.luckygift.v1.RequestMetaR\x04meta\x12\x1d\n" + "\x04meta\x18\x01 \x01(\v2\x1f.hyapp.luckygift.v1.RequestMetaR\x04meta\x12\x1d\n" +
"\n" + "\n" +
@ -5679,7 +5688,8 @@ const file_proto_luckygift_v1_luckygift_proto_rawDesc = "" +
"\x11gift_income_coins\x18\x16 \x01(\x03R\x0fgiftIncomeCoins\x121\n" + "\x11gift_income_coins\x18\x16 \x01(\x03R\x0fgiftIncomeCoins\x121\n" +
"\x15user_registered_at_ms\x18\x17 \x01(\x03R\x12userRegisteredAtMs\x12\x1f\n" + "\x15user_registered_at_ms\x18\x17 \x01(\x03R\x12userRegisteredAtMs\x12\x1f\n" +
"\vroom_locked\x18\x18 \x01(\bR\n" + "\vroom_locked\x18\x18 \x01(\bR\n" +
"roomLocked\"\xd3\x01\n" + "roomLocked\x12\"\n" +
"\rowner_user_id\x18\x19 \x01(\x03R\vownerUserId\"\xd3\x01\n" +
"\x11LuckyGiftRuleTier\x12\x14\n" + "\x11LuckyGiftRuleTier\x12\x14\n" +
"\x05stage\x18\x01 \x01(\tR\x05stage\x12\x17\n" + "\x05stage\x18\x01 \x01(\tR\x05stage\x12\x17\n" +
"\atier_id\x18\x02 \x01(\tR\x06tierId\x12%\n" + "\atier_id\x18\x02 \x01(\tR\x06tierId\x12%\n" +

View File

@ -49,6 +49,8 @@ message LuckyGiftMeta {
int64 user_registered_at_ms = 23; int64 user_registered_at_ms = 23;
// room_locked Room Cell // room_locked Room Cell
bool room_locked = 24; bool room_locked = 24;
// owner_user_id room_locked Room Cell lucky owner
int64 owner_user_id = 25;
} }
message LuckyGiftRuleTier { message LuckyGiftRuleTier {

View File

@ -24067,7 +24067,9 @@ type CreateRedPacketRequest struct {
TotalAmount int64 `protobuf:"varint,8,opt,name=total_amount,json=totalAmount,proto3" json:"total_amount,omitempty"` TotalAmount int64 `protobuf:"varint,8,opt,name=total_amount,json=totalAmount,proto3" json:"total_amount,omitempty"`
PacketCount int32 `protobuf:"varint,9,opt,name=packet_count,json=packetCount,proto3" json:"packet_count,omitempty"` PacketCount int32 `protobuf:"varint,9,opt,name=packet_count,json=packetCount,proto3" json:"packet_count,omitempty"`
// room_locked 由 gateway 从本次发红包前读取的 room-service snapshot 固化wallet 只随资金事实透传。 // room_locked 由 gateway 从本次发红包前读取的 room-service snapshot 固化wallet 只随资金事实透传。
RoomLocked bool `protobuf:"varint,10,opt,name=room_locked,json=roomLocked,proto3" json:"room_locked,omitempty"` RoomLocked bool `protobuf:"varint,10,opt,name=room_locked,json=roomLocked,proto3" json:"room_locked,omitempty"`
// owner_user_id 与 room_locked 来自同一份 room snapshot只用于红包跨房入口的密码框交互。
OwnerUserId int64 `protobuf:"varint,11,opt,name=owner_user_id,json=ownerUserId,proto3" json:"owner_user_id,omitempty"`
unknownFields protoimpl.UnknownFields unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache sizeCache protoimpl.SizeCache
} }
@ -24172,6 +24174,13 @@ func (x *CreateRedPacketRequest) GetRoomLocked() bool {
return false return false
} }
func (x *CreateRedPacketRequest) GetOwnerUserId() int64 {
if x != nil {
return x.OwnerUserId
}
return 0
}
type CreateRedPacketResponse struct { type CreateRedPacketResponse struct {
state protoimpl.MessageState `protogen:"open.v1"` state protoimpl.MessageState `protogen:"open.v1"`
Packet *RedPacket `protobuf:"bytes,1,opt,name=packet,proto3" json:"packet,omitempty"` Packet *RedPacket `protobuf:"bytes,1,opt,name=packet,proto3" json:"packet,omitempty"`
@ -29674,7 +29683,7 @@ const file_proto_wallet_v1_wallet_proto_rawDesc = "" +
" \x01(\tR\aruleUrl\"\x7f\n" + " \x01(\tR\aruleUrl\"\x7f\n" +
"\x1dUpdateRedPacketConfigResponse\x128\n" + "\x1dUpdateRedPacketConfigResponse\x128\n" +
"\x06config\x18\x01 \x01(\v2 .hyapp.wallet.v1.RedPacketConfigR\x06config\x12$\n" + "\x06config\x18\x01 \x01(\v2 .hyapp.wallet.v1.RedPacketConfigR\x06config\x12$\n" +
"\x0eserver_time_ms\x18\x02 \x01(\x03R\fserverTimeMs\"\xd5\x02\n" + "\x0eserver_time_ms\x18\x02 \x01(\x03R\fserverTimeMs\"\xf9\x02\n" +
"\x16CreateRedPacketRequest\x12\x1d\n" + "\x16CreateRedPacketRequest\x12\x1d\n" +
"\n" + "\n" +
"request_id\x18\x01 \x01(\tR\trequestId\x12\x19\n" + "request_id\x18\x01 \x01(\tR\trequestId\x12\x19\n" +
@ -29690,7 +29699,8 @@ const file_proto_wallet_v1_wallet_proto_rawDesc = "" +
"\fpacket_count\x18\t \x01(\x05R\vpacketCount\x12\x1f\n" + "\fpacket_count\x18\t \x01(\x05R\vpacketCount\x12\x1f\n" +
"\vroom_locked\x18\n" + "\vroom_locked\x18\n" +
" \x01(\bR\n" + " \x01(\bR\n" +
"roomLocked\"\x98\x01\n" + "roomLocked\x12\"\n" +
"\rowner_user_id\x18\v \x01(\x03R\vownerUserId\"\x98\x01\n" +
"\x17CreateRedPacketResponse\x122\n" + "\x17CreateRedPacketResponse\x122\n" +
"\x06packet\x18\x01 \x01(\v2\x1a.hyapp.wallet.v1.RedPacketR\x06packet\x12#\n" + "\x06packet\x18\x01 \x01(\v2\x1a.hyapp.wallet.v1.RedPacketR\x06packet\x12#\n" +
"\rbalance_after\x18\x02 \x01(\x03R\fbalanceAfter\x12$\n" + "\rbalance_after\x18\x02 \x01(\x03R\fbalanceAfter\x12$\n" +

View File

@ -2807,6 +2807,8 @@ message CreateRedPacketRequest {
int32 packet_count = 9; int32 packet_count = 9;
// room_locked gateway room-service snapshot wallet // room_locked gateway room-service snapshot wallet
bool room_locked = 10; bool room_locked = 10;
// owner_user_id room_locked room snapshot
int64 owner_user_id = 11;
} }
message CreateRedPacketResponse { message CreateRedPacketResponse {

View File

@ -20,6 +20,7 @@
| `start_ms` / `end_ms` | 查询时间 | 本次查询的毫秒时间范围。 | | `start_ms` / `end_ms` | 查询时间 | 本次查询的毫秒时间范围。 |
| `country_id` / `region_id` | 国家/区域 | 聚合维度;未筛选国家时返回国家分解。 | | `country_id` / `region_id` | 国家/区域 | 聚合维度;未筛选国家时返回国家分解。 |
| `new_users` | 新增用户 | `UserRegistered` 注册 cohort 去重用户数。 | | `new_users` | 新增用户 | `UserRegistered` 注册 cohort 去重用户数。 |
| `profile_setup_view_users` | 填写资料页曝光人数 | Flutter 在未完成资料的新用户真正进入 `profile_setup` 页面后上报 `profile_start`Social 用户日读模型按 canonical 用户或设备主体、统计自然日去重。 |
| `active_users` | 活跃用户 | `stat_social_user_day.active_user` 经设备日级 canonical 身份归并后的用户日去重数;多日汇总为活跃人天。 | | `active_users` | 活跃用户 | `stat_social_user_day.active_user` 经设备日级 canonical 身份归并后的用户日去重数;多日汇总为活跃人天。 |
| `paid_users` | 付费用户 | `WalletRechargeRecorded` 写入 `stat_recharge_day_payers` 后按用户去重。 | | `paid_users` | 付费用户 | `WalletRechargeRecorded` 写入 `stat_recharge_day_payers` 后按用户去重。 |
| `new_paid_users` | 新增付费用户 | 付费用户中注册 cohort 也落在查询周期内的用户数。 | | `new_paid_users` | 新增付费用户 | 付费用户中注册 cohort 也落在查询周期内的用户数。 |

View File

@ -27,6 +27,7 @@ var catalog = map[Code]Spec{
NotFound: spec(codes.NotFound, httpStatusNotFound, NotFound, "not found"), NotFound: spec(codes.NotFound, httpStatusNotFound, NotFound, "not found"),
Conflict: spec(codes.FailedPrecondition, httpStatusConflict, Conflict, "request cannot be completed in current state"), Conflict: spec(codes.FailedPrecondition, httpStatusConflict, Conflict, "request cannot be completed in current state"),
RoomClosed: spec(codes.FailedPrecondition, httpStatusConflict, RoomClosed, "room closed"), RoomClosed: spec(codes.FailedPrecondition, httpStatusConflict, RoomClosed, "room closed"),
RoomUserMuted: spec(codes.PermissionDenied, httpStatusForbidden, RoomUserMuted, "You have been muted and cannot join the mic."),
RoomRPSChallengeTaken: spec( RoomRPSChallengeTaken: spec(
codes.FailedPrecondition, codes.FailedPrecondition,
httpStatusConflict, httpStatusConflict,

View File

@ -19,6 +19,8 @@ const (
Conflict Code = "CONFLICT" Conflict Code = "CONFLICT"
// RoomClosed 表示房间已被后台关闭,客户端不能再进入。 // RoomClosed 表示房间已被后台关闭,客户端不能再进入。
RoomClosed Code = "ROOM_CLOSED" RoomClosed Code = "ROOM_CLOSED"
// RoomUserMuted 表示房间禁言正在阻止用户上麦、发流或重新打开麦克风。
RoomUserMuted Code = "ROOM_USER_MUTED"
// RoomRPSChallengeTaken 表示房内猜拳挑战已被其他用户接单,当前用户不能再重复应战。 // RoomRPSChallengeTaken 表示房内猜拳挑战已被其他用户接单,当前用户不能再重复应战。
RoomRPSChallengeTaken Code = "ROOM_RPS_CHALLENGE_TAKEN" RoomRPSChallengeTaken Code = "ROOM_RPS_CHALLENGE_TAKEN"
// RoomRPSChallengeExpired 表示房内猜拳挑战已越过无人应战截止时间,客户端应关闭应战入口并提示过期。 // RoomRPSChallengeExpired 表示房内猜拳挑战已越过无人应战截止时间,客户端应关闭应战入口并提示过期。

View File

@ -167,6 +167,14 @@ func TestCatalogMappings(t *testing.T) {
publicCode: string(CountryChangeIdentityLocked), publicCode: string(CountryChangeIdentityLocked),
publicMessage: "You already have a host identity. Please contact customer service to change your country.", publicMessage: "You already have a host identity. Please contact customer service to change your country.",
}, },
{
name: "room mute tells user why mic up is blocked",
code: RoomUserMuted,
grpcCode: codes.PermissionDenied,
httpStatus: httpStatusForbidden,
publicCode: string(RoomUserMuted),
publicMessage: "You have been muted and cannot join the mic.",
},
{ {
name: "registered device conflict exposes concrete login reason", name: "registered device conflict exposes concrete login reason",
code: DeviceAlreadyRegistered, code: DeviceAlreadyRegistered,

View File

@ -50,6 +50,7 @@ INCREMENTAL_MIGRATION_FILES=(
"services/wallet-service/deploy/mysql/migrations/013_wallet_outbox_archive_purge_receipts.sql" "services/wallet-service/deploy/mysql/migrations/013_wallet_outbox_archive_purge_receipts.sql"
"services/wallet-service/deploy/mysql/migrations/018_host_salary_policy_revisions.sql" "services/wallet-service/deploy/mysql/migrations/018_host_salary_policy_revisions.sql"
"services/lucky-gift-service/deploy/mysql/migrations/013_jackpot_token_version_rate_limit.sql" "services/lucky-gift-service/deploy/mysql/migrations/013_jackpot_token_version_rate_limit.sql"
"services/statistics-service/deploy/mysql/migrations/003_social_profile_setup_view.sql"
) )
# Keep MySQL as the only required dependency for this script. Business services # Keep MySQL as the only required dependency for this script. Business services

View File

@ -21,6 +21,7 @@ func TestRedPacketEventFromWalletMessageKeepsDisplayPayloadFacts(t *testing.T) {
"room_id":"9001", "room_id":"9001",
"region_id":310, "region_id":310,
"room_locked":true, "room_locked":true,
"owner_user_id":323411852102995968,
"total_amount":15000, "total_amount":15000,
"packet_count":5, "packet_count":5,
"remaining_amount":15000, "remaining_amount":15000,
@ -39,7 +40,7 @@ func TestRedPacketEventFromWalletMessageKeepsDisplayPayloadFacts(t *testing.T) {
if err != nil || !ok { if err != nil || !ok {
t.Fatalf("decode red packet event failed: ok=%t err=%v", ok, err) t.Fatalf("decode red packet event failed: ok=%t err=%v", ok, err)
} }
if event.PacketID != "VRRP2026052712340001" || event.PacketType != "delayed" || event.RoomID != "9001" || event.RegionID != 310 || !event.RoomLocked { if event.PacketID != "VRRP2026052712340001" || event.PacketType != "delayed" || event.RoomID != "9001" || event.RegionID != 310 || !event.RoomLocked || event.OwnerUserID != 323411852102995968 {
t.Fatalf("red packet identity fields mismatch: %+v", event) t.Fatalf("red packet identity fields mismatch: %+v", event)
} }
if event.SenderUserID != 10001 || event.TotalAmount != 15000 || event.TotalCount != 5 || event.RemainingAmount != 15000 || event.RemainingCount != 5 { if event.SenderUserID != 10001 || event.TotalAmount != 15000 || event.TotalCount != 5 || event.RemainingAmount != 15000 || event.RemainingCount != 5 {

View File

@ -123,6 +123,7 @@ func redPacketEventFromWalletMessage(body []byte) (broadcastdomain.RedPacketWall
RegionID: fields.RegionID, RegionID: fields.RegionID,
RegionCode: fields.RegionCode, RegionCode: fields.RegionCode,
RoomLocked: fields.RoomLocked, RoomLocked: fields.RoomLocked,
OwnerUserID: fields.OwnerUserID,
SenderUserID: fields.SenderUserID, SenderUserID: fields.SenderUserID,
TotalAmount: fields.TotalAmount, TotalAmount: fields.TotalAmount,
TotalCount: fields.TotalCount, TotalCount: fields.TotalCount,
@ -291,6 +292,7 @@ type redPacketPayloadFields struct {
RegionID int64 RegionID int64
RegionCode string RegionCode string
RoomLocked bool RoomLocked bool
OwnerUserID int64
SenderUserID int64 SenderUserID int64
TotalAmount int64 TotalAmount int64
TotalCount int32 TotalCount int32
@ -304,7 +306,10 @@ type redPacketPayloadFields struct {
func redPacketFieldsFromWalletPayload(payload string, eventType string) redPacketPayloadFields { func redPacketFieldsFromWalletPayload(payload string, eventType string) redPacketPayloadFields {
var decoded map[string]any var decoded map[string]any
if err := json.Unmarshal([]byte(payload), &decoded); err != nil { decoder := json.NewDecoder(strings.NewReader(payload))
// wallet payload 中的用户 ID 可能是 Snowflake int64UseNumber 必须在首次解码时启用,先落成 float64 后无法恢复精度。
decoder.UseNumber()
if err := decoder.Decode(&decoded); err != nil {
return redPacketPayloadFields{} return redPacketPayloadFields{}
} }
packetID := stringFromDecoded(decoded, "packet_id") packetID := stringFromDecoded(decoded, "packet_id")
@ -332,6 +337,7 @@ func redPacketFieldsFromWalletPayload(payload string, eventType string) redPacke
RegionID: regionID, RegionID: regionID,
RegionCode: stringFromDecoded(decoded, "region_code"), RegionCode: stringFromDecoded(decoded, "region_code"),
RoomLocked: boolFromDecoded(decoded, "room_locked"), RoomLocked: boolFromDecoded(decoded, "room_locked"),
OwnerUserID: int64FromDecoded(decoded, "owner_user_id"),
SenderUserID: int64FromDecoded(decoded, "sender_user_id"), SenderUserID: int64FromDecoded(decoded, "sender_user_id"),
TotalAmount: int64FromDecoded(decoded, "total_amount"), TotalAmount: int64FromDecoded(decoded, "total_amount"),
TotalCount: totalCount, TotalCount: totalCount,

View File

@ -42,6 +42,7 @@ func (s *GRPCRoomBroadcastSource) GetRoomBroadcastSnapshot(ctx context.Context,
RoomID: room.GetRoomId(), RoomID: room.GetRoomId(),
Title: room.GetTitle(), Title: room.GetTitle(),
Locked: room.GetLocked(), Locked: room.GetLocked(),
OwnerUserID: room.GetOwnerUserId(),
VisibleRegionID: room.GetVisibleRegionId(), VisibleRegionID: room.GetVisibleRegionId(),
Status: room.GetStatus(), Status: room.GetStatus(),
}, nil }, nil

View File

@ -127,6 +127,7 @@ type RedPacketWalletEvent struct {
RegionID int64 RegionID int64
RegionCode string RegionCode string
RoomLocked bool RoomLocked bool
OwnerUserID int64
SenderUserID int64 SenderUserID int64
SenderAccount string SenderAccount string
SenderNickname string SenderNickname string

View File

@ -216,19 +216,24 @@ func gameWinPayload(message gamemq.GameOutboxMessage, source map[string]any, eve
} }
if roomID != "" { if roomID != "" {
payload["jump_type"] = "room_window" payload["jump_type"] = "room_window"
payload["jump_payload"] = map[string]any{ jumpPayload := map[string]any{
"room_id": roomID, "room_id": roomID,
"room_locked": room.Locked, "room_locked": room.Locked,
"window": "game_window", "window": "game_window",
"game_id": gameID, "game_id": gameID,
} }
payload["action"] = map[string]any{ action := map[string]any{
"type": "room_window", "type": "room_window",
"room_id": roomID, "room_id": roomID,
"room_locked": room.Locked, "room_locked": room.Locked,
"window": "game_window", "window": "game_window",
"game_id": gameID, "game_id": gameID,
} }
setRoomOwnerSnapshot(payload, room.OwnerUserID)
setRoomOwnerSnapshot(jumpPayload, room.OwnerUserID)
setRoomOwnerSnapshot(action, room.OwnerUserID)
payload["jump_payload"] = jumpPayload
payload["action"] = action
} }
encoded, err := json.Marshal(payload) encoded, err := json.Marshal(payload)
return string(encoded), err return string(encoded), err

View File

@ -142,6 +142,11 @@ func luckyGiftBigWinPayload(envelope *luckygifteventsv1.EventEnvelope, event *lu
} }
// 字段保持拆服前 Flutter 已消费的扁平协议,并补充 granted/pretty ID 快照;不携带 gift_name // 字段保持拆服前 Flutter 已消费的扁平协议,并补充 granted/pretty ID 快照;不携带 gift_name
// 客户端继续用 gift_id 命中本地多语言礼物配置,避免服务端文案覆盖客户端翻译。 // 客户端继续用 gift_id 命中本地多语言礼物配置,避免服务端文案覆盖客户端翻译。
action := map[string]any{
"type": "enter_room",
"room_id": event.GetRoomId(),
"room_locked": event.GetRoomLocked(),
}
payload := map[string]any{ payload := map[string]any{
"event_type": "lucky_gift_drawn", "event_type": "lucky_gift_drawn",
"event_id": eventID, "event_id": eventID,
@ -179,10 +184,7 @@ func luckyGiftBigWinPayload(envelope *luckygifteventsv1.EventEnvelope, event *lu
"draw_created_at_ms": createdAtMS, "draw_created_at_ms": createdAtMS,
"reward_granted_at_ms": event.GetRewardGrantedAtMs(), "reward_granted_at_ms": event.GetRewardGrantedAtMs(),
"sent_at_ms": sentAtMS, "sent_at_ms": sentAtMS,
"action": map[string]any{ "action": action,
"type": "enter_room",
"room_id": event.GetRoomId(),
},
"sender": map[string]any{ "sender": map[string]any{
"user_id": sender.UserID, "user_id": sender.UserID,
"display_user_id": sender.Account, "display_user_id": sender.Account,
@ -191,6 +193,8 @@ func luckyGiftBigWinPayload(envelope *luckygifteventsv1.EventEnvelope, event *lu
"avatar": sender.Avatar, "avatar": sender.Avatar,
}, },
} }
setRoomOwnerSnapshot(payload, event.GetOwnerUserId())
setRoomOwnerSnapshot(action, event.GetOwnerUserId())
encoded, err := json.Marshal(payload) encoded, err := json.Marshal(payload)
return string(encoded), err return string(encoded), err
} }

View File

@ -117,33 +117,38 @@ func redPacketIMPayload(event broadcastdomain.RedPacketWalletEvent, eventName st
if status == "" { if status == "" {
status = "ACTIVE" status = "ACTIVE"
} }
data := map[string]any{
"event": eventName,
"scope": scope,
"packetNo": event.PacketID,
"packetId": event.PacketID,
"roomId": event.RoomID,
"regionId": event.RegionID,
"regionCode": event.RegionCode,
"room_locked": event.RoomLocked,
"packetMode": packetMode,
"senderUserId": redPacketUserIDString(event.SenderUserID),
"actualAccount": redPacketSenderAccount(event),
"userNickname": event.SenderNickname,
"userAvatar": event.SenderAvatar,
"totalAmount": event.TotalAmount,
"totalCount": event.TotalCount,
"remainAmount": event.RemainingAmount,
"remainCount": event.RemainingCount,
"status": status,
"delaySeconds": redPacketDelaySeconds(event),
"refundedAmount": event.RefundedAmount,
"claimStartTime": event.OpenAtMS,
"expireTime": event.ExpiresAtMS,
"serverTime": event.CreatedAtMS,
}
if event.OwnerUserID > 0 {
// 红包协议使用独立 data 包装;用户 ID 直接写字符串,避免任何未经过统一播报归一化的调用方暴露 JSON 数字精度问题。
data["owner_user_id"] = redPacketUserIDString(event.OwnerUserID)
}
payload := map[string]any{ payload := map[string]any{
"type": "ROOM_RED_PACKET", "type": "ROOM_RED_PACKET",
"data": map[string]any{ "data": data,
"event": eventName,
"scope": scope,
"packetNo": event.PacketID,
"packetId": event.PacketID,
"roomId": event.RoomID,
"regionId": event.RegionID,
"regionCode": event.RegionCode,
"room_locked": event.RoomLocked,
"packetMode": packetMode,
"senderUserId": redPacketUserIDString(event.SenderUserID),
"actualAccount": redPacketSenderAccount(event),
"userNickname": event.SenderNickname,
"userAvatar": event.SenderAvatar,
"totalAmount": event.TotalAmount,
"totalCount": event.TotalCount,
"remainAmount": event.RemainingAmount,
"remainCount": event.RemainingCount,
"status": status,
"delaySeconds": redPacketDelaySeconds(event),
"refundedAmount": event.RefundedAmount,
"claimStartTime": event.OpenAtMS,
"expireTime": event.ExpiresAtMS,
"serverTime": event.CreatedAtMS,
},
} }
encoded, err := json.Marshal(payload) encoded, err := json.Marshal(payload)
if err != nil { if err != nil {

View File

@ -72,6 +72,7 @@ type RoomBroadcastSnapshot struct {
RoomID string RoomID string
Title string Title string
Locked bool Locked bool
OwnerUserID int64
VisibleRegionID int64 VisibleRegionID int64
Status string Status string
} }
@ -904,6 +905,11 @@ func superGiftBroadcastEventID(envelope *roomeventsv1.EventEnvelope, gift *roome
func superGiftPayload(envelope *roomeventsv1.EventEnvelope, gift *roomeventsv1.RoomGiftSent, eventID string, sentAtMS int64, sender SenderProfile, receiver SenderProfile) (string, error) { func superGiftPayload(envelope *roomeventsv1.EventEnvelope, gift *roomeventsv1.RoomGiftSent, eventID string, sentAtMS int64, sender SenderProfile, receiver SenderProfile) (string, error) {
// payload 同时保留扁平字段和 sender/receiver 对象,旧客户端继续读原字段,新客户端可直接取完整用户资料。 // payload 同时保留扁平字段和 sender/receiver 对象,旧客户端继续读原字段,新客户端可直接取完整用户资料。
action := map[string]any{
"type": "enter_room",
"room_id": envelope.GetRoomId(),
"room_locked": gift.GetRoomLocked(),
}
payload := map[string]any{ payload := map[string]any{
"event_id": eventID, "event_id": eventID,
"broadcast_type": broadcastdomain.TypeSuperGift, "broadcast_type": broadcastdomain.TypeSuperGift,
@ -939,11 +945,10 @@ func superGiftPayload(envelope *roomeventsv1.EventEnvelope, gift *roomeventsv1.R
"source_event_id": envelope.GetEventId(), "source_event_id": envelope.GetEventId(),
"sender": broadcastProfilePayload(sender), "sender": broadcastProfilePayload(sender),
"receiver": broadcastProfilePayload(receiver), "receiver": broadcastProfilePayload(receiver),
"action": map[string]any{ "action": action,
"type": "enter_room",
"room_id": envelope.GetRoomId(),
},
} }
setRoomOwnerSnapshot(payload, gift.GetOwnerUserId())
setRoomOwnerSnapshot(action, gift.GetOwnerUserId())
encoded, err := json.Marshal(payload) encoded, err := json.Marshal(payload)
return string(encoded), err return string(encoded), err
} }
@ -1000,6 +1005,11 @@ func roomPasswordChangedPayload(envelope *roomeventsv1.EventEnvelope, event *roo
} }
func roomRocketPayload(envelope *roomeventsv1.EventEnvelope, rocket *roomeventsv1.RoomRocketIgnited, eventID string, sentAtMS int64, igniter SenderProfile) (string, error) { func roomRocketPayload(envelope *roomeventsv1.EventEnvelope, rocket *roomeventsv1.RoomRocketIgnited, eventID string, sentAtMS int64, igniter SenderProfile) (string, error) {
action := map[string]any{
"type": "enter_room",
"room_id": envelope.GetRoomId(),
"room_locked": rocket.GetRoomLocked(),
}
payload := map[string]any{ payload := map[string]any{
"event_id": eventID, "event_id": eventID,
"broadcast_type": broadcastdomain.TypeRoomRocket, "broadcast_type": broadcastdomain.TypeRoomRocket,
@ -1027,16 +1037,23 @@ func roomRocketPayload(envelope *roomeventsv1.EventEnvelope, rocket *roomeventsv
"current_fuel": rocket.GetCurrentFuel(), "current_fuel": rocket.GetCurrentFuel(),
"fuel_threshold": rocket.GetFuelThreshold(), "fuel_threshold": rocket.GetFuelThreshold(),
"ignited_at_ms": rocket.GetIgnitedAtMs(), "ignited_at_ms": rocket.GetIgnitedAtMs(),
"action": map[string]any{ "action": action,
"type": "enter_room",
"room_id": envelope.GetRoomId(),
"room_locked": rocket.GetRoomLocked(),
},
} }
setRoomOwnerSnapshot(payload, rocket.GetOwnerUserId())
setRoomOwnerSnapshot(action, rocket.GetOwnerUserId())
encoded, err := json.Marshal(payload) encoded, err := json.Marshal(payload)
return string(encoded), err return string(encoded), err
} }
func setRoomOwnerSnapshot(payload map[string]any, ownerUserID int64) {
if payload == nil || ownerUserID <= 0 {
// 旧事件没有 owner_user_id 时必须保持字段缺失,让客户端继续弹密码框,不能发送可被误判为真实身份的 0。
return
}
// 所有出站用户 ID 都使用十进制字符串,避免 JSON/JavaScript Number 截断 Snowflake int64。
payload["owner_user_id"] = strconv.FormatInt(ownerUserID, 10)
}
func roomRocketRewardPayload(envelope *roomeventsv1.EventEnvelope, event *roomeventsv1.RoomRocketRewardGranted, eventID string, sentAtMS int64, profiles map[int64]SenderProfile) (string, error) { func roomRocketRewardPayload(envelope *roomeventsv1.EventEnvelope, event *roomeventsv1.RoomRocketRewardGranted, eventID string, sentAtMS int64, profiles map[int64]SenderProfile) (string, error) {
rewards := make([]map[string]any, 0, len(event.GetRewards())) rewards := make([]map[string]any, 0, len(event.GetRewards()))
for _, reward := range event.GetRewards() { for _, reward := range event.GetRewards() {

View File

@ -96,6 +96,7 @@ func TestHandleRoomGiftSentCreatesRegionBroadcast(t *testing.T) {
GiftValue: 999, GiftValue: 999,
VisibleRegionId: 1001, VisibleRegionId: 1001,
RoomLocked: true, RoomLocked: true,
OwnerUserId: 323411852102995968,
CommandId: "cmd-send-gift-1", CommandId: "cmd-send-gift-1",
GiftName: "Rocket", GiftName: "Rocket",
GiftIconUrl: "https://cdn.example/rocket.png", GiftIconUrl: "https://cdn.example/rocket.png",
@ -119,9 +120,13 @@ func TestHandleRoomGiftSentCreatesRegionBroadcast(t *testing.T) {
if err := json.Unmarshal([]byte(record.PayloadJSON), &payload); err != nil { if err := json.Unmarshal([]byte(record.PayloadJSON), &payload); err != nil {
t.Fatalf("payload is not json: %v", err) t.Fatalf("payload is not json: %v", err)
} }
if payload["room_id"] != "room-1001" || payload["room_locked"] != true || payload["gift_id"] != "rocket" || payload["scope"] != "region" || payload["gift_icon_url"] != "https://cdn.example/rocket.png" || payload["sender_vip_level"] != float64(7) || payload["sender_gift_broadcast_animation_url"] != "https://cdn.example/vip-gift-tray.svga" { if payload["room_id"] != "room-1001" || payload["room_locked"] != true || payload["owner_user_id"] != "323411852102995968" || payload["gift_id"] != "rocket" || payload["scope"] != "region" || payload["gift_icon_url"] != "https://cdn.example/rocket.png" || payload["sender_vip_level"] != float64(7) || payload["sender_gift_broadcast_animation_url"] != "https://cdn.example/vip-gift-tray.svga" {
t.Fatalf("payload mismatch: %+v", payload) t.Fatalf("payload mismatch: %+v", payload)
} }
action, _ := payload["action"].(map[string]any)
if action["room_locked"] != true || action["owner_user_id"] != "323411852102995968" {
t.Fatalf("gift action must carry room owner snapshot: %+v", action)
}
sender, _ := payload["sender"].(map[string]any) sender, _ := payload["sender"].(map[string]any)
receiver, _ := payload["receiver"].(map[string]any) receiver, _ := payload["receiver"].(map[string]any)
if sender["nickname"] != "Sender" || sender["avatar_frame_url"] != "https://cdn.example/sender-frame.png" || receiver["nickname"] != "Receiver" { if sender["nickname"] != "Sender" || sender["avatar_frame_url"] != "https://cdn.example/sender-frame.png" || receiver["nickname"] != "Receiver" {
@ -158,6 +163,7 @@ func TestHandleLuckyGiftEventCreatesTenTimesRegionBroadcastIdempotently(t *testi
SenderPrettyDisplayUserId: "888042", SenderPrettyDisplayUserId: "888042",
VisibleRegionId: 210, VisibleRegionId: 210,
RoomLocked: true, RoomLocked: true,
OwnerUserId: 323411852102995968,
CountryId: 15, CountryId: 15,
CoinSpent: 100, CoinSpent: 100,
RuleVersion: 7, RuleVersion: 7,
@ -191,14 +197,14 @@ func TestHandleLuckyGiftEventCreatesTenTimesRegionBroadcastIdempotently(t *testi
if payload["event_type"] != "lucky_gift_drawn" || payload["reward_status"] != "granted" || payload["sender_name"] != "Real Sender" || payload["sender_pretty_display_user_id"] != "888042" { if payload["event_type"] != "lucky_gift_drawn" || payload["reward_status"] != "granted" || payload["sender_name"] != "Real Sender" || payload["sender_pretty_display_user_id"] != "888042" {
t.Fatalf("lucky gift payload identity/profile mismatch: %+v", payload) t.Fatalf("lucky gift payload identity/profile mismatch: %+v", payload)
} }
if payload["room_id"] != "room-210" || payload["room_locked"] != true || payload["draw_id"] != "lucky_draw_10x" || payload["multiplier_ppm"] != float64(luckygiftmq.LuckyGiftDisplayMinMultiplierPPM) || payload["effective_reward_coins"] != float64(1000) || payload["sent_at_ms"] != float64(1_700_000_001_000) { if payload["room_id"] != "room-210" || payload["room_locked"] != true || payload["owner_user_id"] != "323411852102995968" || payload["draw_id"] != "lucky_draw_10x" || payload["multiplier_ppm"] != float64(luckygiftmq.LuckyGiftDisplayMinMultiplierPPM) || payload["effective_reward_coins"] != float64(1000) || payload["sent_at_ms"] != float64(1_700_000_001_000) {
t.Fatalf("lucky gift payload reward mismatch: %+v", payload) t.Fatalf("lucky gift payload reward mismatch: %+v", payload)
} }
if _, exists := payload["gift_name"]; exists { if _, exists := payload["gift_name"]; exists {
t.Fatalf("region payload must let Flutter resolve localized gift name: %+v", payload) t.Fatalf("region payload must let Flutter resolve localized gift name: %+v", payload)
} }
action, _ := payload["action"].(map[string]any) action, _ := payload["action"].(map[string]any)
if action["type"] != "enter_room" || action["room_id"] != "room-210" { if action["type"] != "enter_room" || action["room_id"] != "room-210" || action["room_locked"] != true || action["owner_user_id"] != "323411852102995968" {
t.Fatalf("lucky gift action mismatch: %+v", action) t.Fatalf("lucky gift action mismatch: %+v", action)
} }
sender, _ := payload["sender"].(map[string]any) sender, _ := payload["sender"].(map[string]any)
@ -220,7 +226,7 @@ func TestHandleGameWinUsesAppThresholdAndLockedRoomSnapshot(t *testing.T) {
service := New(Config{NodeID: "node-game-win", GameWinMinCoins: 100000}, repository, nil, nil) service := New(Config{NodeID: "node-game-win", GameWinMinCoins: 100000}, repository, nil, nil)
service.SetClock(func() time.Time { return time.UnixMilli(1_800_000_000_000).UTC() }) service.SetClock(func() time.Time { return time.UnixMilli(1_800_000_000_000).UTC() })
service.SetSenderProfileSource(fakeSenderProfileSource{profile: SenderProfile{UserID: 42, Account: "160042", Nickname: "Winner", Avatar: "https://cdn.example/winner.png"}}) service.SetSenderProfileSource(fakeSenderProfileSource{profile: SenderProfile{UserID: 42, Account: "160042", Nickname: "Winner", Avatar: "https://cdn.example/winner.png"}})
service.SetRoomSnapshotSource(fakeRoomBroadcastSource{snapshot: RoomBroadcastSnapshot{RoomID: "room-310", Title: "Game Room", Locked: true, VisibleRegionID: 310, Status: "active"}}) service.SetRoomSnapshotSource(fakeRoomBroadcastSource{snapshot: RoomBroadcastSnapshot{RoomID: "room-310", Title: "Game Room", Locked: true, OwnerUserID: 323411852102995968, VisibleRegionID: 310, Status: "active"}})
ctx := appcode.WithContext(context.Background(), "fami") ctx := appcode.WithContext(context.Background(), "fami")
config, err := service.UpdateGameWinBroadcastConfig(ctx, true, 500, 7) config, err := service.UpdateGameWinBroadcastConfig(ctx, true, 500, 7)
if err != nil || config.MinWinCoins != 500 || config.UpdatedByAdminID != 7 { if err != nil || config.MinWinCoins != 500 || config.UpdatedByAdminID != 7 {
@ -248,13 +254,17 @@ func TestHandleGameWinUsesAppThresholdAndLockedRoomSnapshot(t *testing.T) {
if err := json.Unmarshal([]byte(record.PayloadJSON), &payload); err != nil { if err := json.Unmarshal([]byte(record.PayloadJSON), &payload); err != nil {
t.Fatalf("decode game win payload: %v", err) t.Fatalf("decode game win payload: %v", err)
} }
if payload["room_id"] != "room-310" || payload["room_locked"] != true || payload["coins"] != float64(500) || payload["game_name"] != "Slots" || payload["user_name"] != "Winner" { if payload["room_id"] != "room-310" || payload["room_locked"] != true || payload["owner_user_id"] != "323411852102995968" || payload["coins"] != float64(500) || payload["game_name"] != "Slots" || payload["user_name"] != "Winner" {
t.Fatalf("game win payload mismatch: %+v", payload) t.Fatalf("game win payload mismatch: %+v", payload)
} }
jump, _ := payload["jump_payload"].(map[string]any) jump, _ := payload["jump_payload"].(map[string]any)
if jump["room_locked"] != true || jump["window"] != "game_window" { if jump["room_locked"] != true || jump["owner_user_id"] != "323411852102995968" || jump["window"] != "game_window" {
t.Fatalf("game win jump must carry lock and game window: %+v", jump) t.Fatalf("game win jump must carry lock and game window: %+v", jump)
} }
action, _ := payload["action"].(map[string]any)
if action["owner_user_id"] != "323411852102995968" {
t.Fatalf("game win action must carry owner snapshot: %+v", action)
}
message.OrderID = "order-below" message.OrderID = "order-below"
message.EventID = "GameOrderSettled:order-below" message.EventID = "GameOrderSettled:order-below"
@ -381,6 +391,7 @@ func TestRoomRocketPayloadCarriesRoomLockSnapshot(t *testing.T) {
RocketId: "rocket-1", RocketId: "rocket-1",
BroadcastScope: broadcastdomain.ScopeGlobal, BroadcastScope: broadcastdomain.ScopeGlobal,
RoomLocked: true, RoomLocked: true,
OwnerUserId: 323411852102995968,
}, "rocket-event-1", 1_700_000_000_000, SenderProfile{}) }, "rocket-event-1", 1_700_000_000_000, SenderProfile{})
if err != nil { if err != nil {
t.Fatalf("roomRocketPayload failed: %v", err) t.Fatalf("roomRocketPayload failed: %v", err)
@ -390,7 +401,7 @@ func TestRoomRocketPayloadCarriesRoomLockSnapshot(t *testing.T) {
t.Fatalf("rocket payload is not json: %v", err) t.Fatalf("rocket payload is not json: %v", err)
} }
action, _ := payload["action"].(map[string]any) action, _ := payload["action"].(map[string]any)
if payload["room_locked"] != true || action["room_locked"] != true { if payload["room_locked"] != true || action["room_locked"] != true || payload["owner_user_id"] != "323411852102995968" || action["owner_user_id"] != "323411852102995968" {
t.Fatalf("rocket payload and action must carry room lock snapshot: %+v", payload) t.Fatalf("rocket payload and action must carry room lock snapshot: %+v", payload)
} }
} }
@ -511,6 +522,7 @@ func TestConsumeRedPacketCreatedBuildsFullIMPayload(t *testing.T) {
RoomID: "9001", RoomID: "9001",
RegionID: 310, RegionID: 310,
RoomLocked: true, RoomLocked: true,
OwnerUserID: 323411852102995968,
SenderUserID: 10001, SenderUserID: 10001,
TotalAmount: 15000, TotalAmount: 15000,
TotalCount: 5, TotalCount: 5,
@ -543,6 +555,9 @@ func TestConsumeRedPacketCreatedBuildsFullIMPayload(t *testing.T) {
if data["room_locked"] != true { if data["room_locked"] != true {
t.Fatalf("red packet payload must carry room lock snapshot: %+v", data) t.Fatalf("red packet payload must carry room lock snapshot: %+v", data)
} }
if data["owner_user_id"] != "323411852102995968" {
t.Fatalf("red packet payload must carry owner snapshot without JSON precision loss: %+v", data)
}
} }
func TestDelayedRedPacketCreatedSendsRegionAndSchedulesOpenDueIM(t *testing.T) { func TestDelayedRedPacketCreatedSendsRegionAndSchedulesOpenDueIM(t *testing.T) {
@ -568,6 +583,7 @@ func TestDelayedRedPacketCreatedSendsRegionAndSchedulesOpenDueIM(t *testing.T) {
RoomID: "9001", RoomID: "9001",
RegionID: 310, RegionID: 310,
RoomLocked: true, RoomLocked: true,
OwnerUserID: 323411852102995968,
SenderUserID: 10001, SenderUserID: 10001,
TotalAmount: 15000, TotalAmount: 15000,
TotalCount: 5, TotalCount: 5,
@ -596,11 +612,11 @@ func TestDelayedRedPacketCreatedSendsRegionAndSchedulesOpenDueIM(t *testing.T) {
t.Fatalf("open due outbox mismatch: %+v", openDue) t.Fatalf("open due outbox mismatch: %+v", openDue)
} }
regionData := redPacketPayloadData(t, regionCreated.PayloadJSON) regionData := redPacketPayloadData(t, regionCreated.PayloadJSON)
if regionData["event"] != "CREATED" || regionData["scope"] != "REGION" || regionData["packetMode"] != "DELAYED" || regionData["room_locked"] != true { if regionData["event"] != "CREATED" || regionData["scope"] != "REGION" || regionData["packetMode"] != "DELAYED" || regionData["room_locked"] != true || regionData["owner_user_id"] != "323411852102995968" {
t.Fatalf("region created payload mismatch: %+v", regionData) t.Fatalf("region created payload mismatch: %+v", regionData)
} }
openDueData := redPacketPayloadData(t, openDue.PayloadJSON) openDueData := redPacketPayloadData(t, openDue.PayloadJSON)
if openDueData["event"] != "OPEN_DUE" || openDueData["scope"] != "ROOM" || openDueData["claimStartTime"] != float64(openAtMS) || openDueData["room_locked"] != true { if openDueData["event"] != "OPEN_DUE" || openDueData["scope"] != "ROOM" || openDueData["claimStartTime"] != float64(openAtMS) || openDueData["room_locked"] != true || openDueData["owner_user_id"] != "323411852102995968" {
t.Fatalf("open due payload mismatch: %+v", openDueData) t.Fatalf("open due payload mismatch: %+v", openDueData)
} }

View File

@ -71,6 +71,7 @@ type fakeRoomClient struct {
updateProfileErr error updateProfileErr error
joinErr error joinErr error
joinResp *roomv1.JoinRoomResponse joinResp *roomv1.JoinRoomResponse
micUpErr error
} }
type fakeRoomVIPClient struct { type fakeRoomVIPClient struct {
@ -219,6 +220,9 @@ func (f *fakeRoomClient) LeaveRoom(_ context.Context, req *roomv1.LeaveRoomReque
func (f *fakeRoomClient) MicUp(_ context.Context, req *roomv1.MicUpRequest) (*roomv1.MicUpResponse, error) { func (f *fakeRoomClient) MicUp(_ context.Context, req *roomv1.MicUpRequest) (*roomv1.MicUpResponse, error) {
f.lastMicUp = req f.lastMicUp = req
if f.micUpErr != nil {
return nil, f.micUpErr
}
return &roomv1.MicUpResponse{Result: &roomv1.CommandResult{Applied: true, RoomVersion: 4}}, nil return &roomv1.MicUpResponse{Result: &roomv1.CommandResult{Applied: true, RoomVersion: 4}}, nil
} }
@ -11744,6 +11748,32 @@ func TestMapReasonToHTTPMapsGenericConflict(t *testing.T) {
} }
} }
func TestMicUpReturnsExplicitRoomMutedMessage(t *testing.T) {
// Flutter 只消费 gateway envelope这里锁定跨 gRPC 边界后的公开 code/message避免再次退化成通用 permission denied。
client := &fakeRoomClient{
micUpErr: xerr.ToGRPCError(xerr.New(xerr.RoomUserMuted, "user is muted")),
}
router := NewHandler(client).Routes(auth.NewVerifier("secret"))
request := httptest.NewRequest(
http.MethodPost,
"/api/v1/rooms/mic/up",
bytes.NewReader([]byte(`{"room_id":"lalu_room-muted","command_id":"mic-up-muted","seat_no":2}`)),
)
request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, request)
assertEnvelopeMessage(
t,
recorder,
http.StatusForbidden,
string(xerr.RoomUserMuted),
"You have been muted and cannot join the mic.",
"",
)
}
func TestRoutesWriteUnauthorizedEnvelope(t *testing.T) { func TestRoutesWriteUnauthorizedEnvelope(t *testing.T) {
router := NewHandler(&fakeRoomClient{}).Routes(auth.NewVerifier("secret")) router := NewHandler(&fakeRoomClient{}).Routes(auth.NewVerifier("secret"))
request := httptest.NewRequest(http.MethodPost, "/api/v1/rooms/create", bytes.NewReader([]byte(`{}`))) request := httptest.NewRequest(http.MethodPost, "/api/v1/rooms/create", bytes.NewReader([]byte(`{}`)))

View File

@ -261,6 +261,7 @@ func (h *Handler) sendVoiceRoomRedPacket(writer http.ResponseWriter, request *ht
TotalAmount: body.amount(), TotalAmount: body.amount(),
PacketCount: body.count(), PacketCount: body.count(),
RoomLocked: snapshotResp.GetRoom().GetLocked(), RoomLocked: snapshotResp.GetRoom().GetLocked(),
OwnerUserId: snapshotResp.GetRoom().GetOwnerUserId(),
}) })
if err != nil { if err != nil {
writeRedPacketRPCError(writer, request, err) writeRedPacketRPCError(writer, request, err)
@ -706,6 +707,7 @@ func (h *Handler) createRoomRedPacket(writer http.ResponseWriter, request *http.
TotalAmount: totalAmount, TotalAmount: totalAmount,
PacketCount: packetCount, PacketCount: packetCount,
RoomLocked: snapshotResp.GetRoom().GetLocked(), RoomLocked: snapshotResp.GetRoom().GetLocked(),
OwnerUserId: snapshotResp.GetRoom().GetOwnerUserId(),
}) })
if err != nil { if err != nil {
httpkit.WriteRPCError(writer, request, err) httpkit.WriteRPCError(writer, request, err)

View File

@ -214,7 +214,9 @@ type DrawCommand struct {
UserRegisteredAtMS int64 UserRegisteredAtMS int64
VisibleRegionID int64 VisibleRegionID int64
// RoomLocked 是原始送礼时的房间展示快照,只能随 owner fact 透传,不能参与中奖概率和账务语义。 // RoomLocked 是原始送礼时的房间展示快照,只能随 owner fact 透传,不能参与中奖概率和账务语义。
RoomLocked bool RoomLocked bool
// OwnerUserID 与 RoomLocked 来自同一次 Room Cell 送礼快照;它只服务跨房密码框交互,不参与抽奖资格或返奖。
OwnerUserID int64
CountryID int64 CountryID int64
Recharge7DCoins int64 Recharge7DCoins int64
Recharge30DCoins int64 Recharge30DCoins int64

View File

@ -73,7 +73,7 @@ func TestPublishLuckyGiftDrawnFactCarriesGrantedSnapshot(t *testing.T) {
if err := proto.Unmarshal(publisher.envelope.GetBody(), &fact); err != nil { if err := proto.Unmarshal(publisher.envelope.GetBody(), &fact); err != nil {
t.Fatalf("decode owner fact: %v", err) t.Fatalf("decode owner fact: %v", err)
} }
if fact.GetRewardStatus() != domain.StatusGranted || fact.GetWalletTransactionId() != "wallet-tx-1" || fact.GetMultiplierPpm() != luckygiftmq.LuckyGiftDisplayMinMultiplierPPM || fact.GetSenderName() != "Lucky Sender" || !fact.GetRoomLocked() || fact.GetRewardGrantedAtMs() != 1783940398320 { if fact.GetRewardStatus() != domain.StatusGranted || fact.GetWalletTransactionId() != "wallet-tx-1" || fact.GetMultiplierPpm() != luckygiftmq.LuckyGiftDisplayMinMultiplierPPM || fact.GetSenderName() != "Lucky Sender" || !fact.GetRoomLocked() || fact.GetOwnerUserId() != 323411852102995968 || fact.GetRewardGrantedAtMs() != 1783940398320 {
t.Fatalf("unexpected owner fact: %+v", &fact) t.Fatalf("unexpected owner fact: %+v", &fact)
} }
} }
@ -165,6 +165,7 @@ func testLuckyGiftDrawnPayload() luckyGiftDrawnPayload {
SenderDisplayUserID: "123456", SenderDisplayUserID: "123456",
VisibleRegionID: 1, VisibleRegionID: 1,
RoomLocked: true, RoomLocked: true,
OwnerUserID: 323411852102995968,
CountryID: 86, CountryID: 86,
CoinSpent: 100, CoinSpent: 100,
MultiplierPPM: luckygiftmq.LuckyGiftDisplayMinMultiplierPPM, MultiplierPPM: luckygiftmq.LuckyGiftDisplayMinMultiplierPPM,

View File

@ -526,6 +526,7 @@ func (s *Service) creditLuckyGiftRewardFastPath(ctx context.Context, cmd domain.
SenderPrettyDisplayUserID: cmd.SenderPrettyDisplayUserID, SenderPrettyDisplayUserID: cmd.SenderPrettyDisplayUserID,
VisibleRegionID: cmd.VisibleRegionID, VisibleRegionID: cmd.VisibleRegionID,
RoomLocked: cmd.RoomLocked, RoomLocked: cmd.RoomLocked,
OwnerUserID: cmd.OwnerUserID,
CountryID: cmd.CountryID, CountryID: cmd.CountryID,
CoinSpent: cmd.CoinSpent, CoinSpent: cmd.CoinSpent,
RuleVersion: result.RuleVersion, RuleVersion: result.RuleVersion,
@ -600,6 +601,7 @@ func (s *Service) publishLuckyGiftDrawnFact(ctx context.Context, payload luckyGi
SenderPrettyDisplayUserId: payload.SenderPrettyDisplayUserID, SenderPrettyDisplayUserId: payload.SenderPrettyDisplayUserID,
VisibleRegionId: payload.VisibleRegionID, VisibleRegionId: payload.VisibleRegionID,
RoomLocked: payload.RoomLocked, RoomLocked: payload.RoomLocked,
OwnerUserId: payload.OwnerUserID,
CountryId: payload.CountryID, CountryId: payload.CountryID,
CoinSpent: payload.CoinSpent, CoinSpent: payload.CoinSpent,
RuleVersion: payload.RuleVersion, RuleVersion: payload.RuleVersion,
@ -1086,6 +1088,7 @@ type luckyGiftDrawnPayload struct {
SenderPrettyDisplayUserID string `json:"sender_pretty_display_user_id,omitempty"` SenderPrettyDisplayUserID string `json:"sender_pretty_display_user_id,omitempty"`
VisibleRegionID int64 `json:"visible_region_id"` VisibleRegionID int64 `json:"visible_region_id"`
RoomLocked bool `json:"room_locked"` RoomLocked bool `json:"room_locked"`
OwnerUserID int64 `json:"owner_user_id,omitempty"`
CountryID int64 `json:"country_id"` CountryID int64 `json:"country_id"`
CoinSpent int64 `json:"coin_spent"` CoinSpent int64 `json:"coin_spent"`
RuleVersion int64 `json:"rule_version"` RuleVersion int64 `json:"rule_version"`

View File

@ -1232,6 +1232,7 @@ func (r *Repository) insertLuckyAggregateRewardSettlementOutbox(ctx context.Cont
"sender_pretty_display_user_id": cmd.SenderPrettyDisplayUserID, "sender_pretty_display_user_id": cmd.SenderPrettyDisplayUserID,
"visible_region_id": cmd.VisibleRegionID, "visible_region_id": cmd.VisibleRegionID,
"room_locked": cmd.RoomLocked, "room_locked": cmd.RoomLocked,
"owner_user_id": cmd.OwnerUserID,
"country_id": cmd.CountryID, "country_id": cmd.CountryID,
"room_id": cmd.RoomID, "room_id": cmd.RoomID,
"gift_id": cmd.GiftID, "gift_id": cmd.GiftID,
@ -1611,6 +1612,7 @@ func (r *Repository) applyLuckyDraw(ctx context.Context, tx *sql.Tx, input lucky
"sender_pretty_display_user_id": input.Command.SenderPrettyDisplayUserID, "sender_pretty_display_user_id": input.Command.SenderPrettyDisplayUserID,
"visible_region_id": input.Command.VisibleRegionID, "visible_region_id": input.Command.VisibleRegionID,
"room_locked": input.Command.RoomLocked, "room_locked": input.Command.RoomLocked,
"owner_user_id": input.Command.OwnerUserID,
"country_id": input.Command.CountryID, "country_id": input.Command.CountryID,
"room_id": input.Command.RoomID, "room_id": input.Command.RoomID,
"gift_id": input.Command.GiftID, "gift_id": input.Command.GiftID,

View File

@ -123,6 +123,7 @@ func luckyDrawCommandFromProto(meta *luckygiftv1.LuckyGiftMeta) domain.DrawComma
UserRegisteredAtMS: meta.GetUserRegisteredAtMs(), UserRegisteredAtMS: meta.GetUserRegisteredAtMs(),
VisibleRegionID: meta.GetVisibleRegionId(), VisibleRegionID: meta.GetVisibleRegionId(),
RoomLocked: meta.GetRoomLocked(), RoomLocked: meta.GetRoomLocked(),
OwnerUserID: meta.GetOwnerUserId(),
CountryID: meta.GetCountryId(), CountryID: meta.GetCountryId(),
SenderName: meta.GetSenderName(), SenderName: meta.GetSenderName(),
SenderAvatar: meta.GetSenderAvatar(), SenderAvatar: meta.GetSenderAvatar(),

View File

@ -10,7 +10,7 @@ import (
"hyapp/services/room-service/internal/room/state" "hyapp/services/room-service/internal/room/state"
) )
func buildRoomGiftSentRecords(roomID string, roomVersion int64, now time.Time, cmd command.SendGift, roomMeta RoomMeta, roomLocked bool, targetBillings []giftTargetBilling, targetCurrentGiftValues map[int64]int64) ([]outbox.Record, error) { func buildRoomGiftSentRecords(roomID string, roomVersion int64, now time.Time, cmd command.SendGift, roomMeta RoomMeta, roomLocked bool, ownerUserID int64, targetBillings []giftTargetBilling, targetCurrentGiftValues map[int64]int64) ([]outbox.Record, error) {
// RoomGiftSent 是统计、活动、成长任务和房间展示共同消费的送礼事实;构造时必须按目标拆分,不能用 batch 展示消息替代逐目标账务事实。 // RoomGiftSent 是统计、活动、成长任务和房间展示共同消费的送礼事实;构造时必须按目标拆分,不能用 batch 展示消息替代逐目标账务事实。
records := make([]outbox.Record, 0, len(targetBillings)) records := make([]outbox.Record, 0, len(targetBillings))
for _, targetBilling := range targetBillings { for _, targetBilling := range targetBillings {
@ -49,6 +49,7 @@ func buildRoomGiftSentRecords(roomID string, roomVersion int64, now time.Time, c
// effect_types 来自 wallet-service 已锁定的礼物价格配置room-service 只透传,避免统计和全服广播各自重新判定礼物效果。 // effect_types 来自 wallet-service 已锁定的礼物价格配置room-service 只透传,避免统计和全服广播各自重新判定礼物效果。
GiftEffectTypes: targetBilling.Billing.GetGiftEffectTypes(), GiftEffectTypes: targetBilling.Billing.GetGiftEffectTypes(),
RoomLocked: roomLocked, RoomLocked: roomLocked,
OwnerUserId: ownerUserID,
IsRobotGift: cmd.RobotWalletGift, IsRobotGift: cmd.RobotWalletGift,
SyntheticLuckyGift: cmd.SyntheticLuckyGift, SyntheticLuckyGift: cmd.SyntheticLuckyGift,
RealRoomRobotGift: cmd.RobotWalletGift && !cmd.RobotGift, RealRoomRobotGift: cmd.RobotWalletGift && !cmd.RobotGift,

View File

@ -60,7 +60,7 @@ func TestBuildRoomGiftSentRecordsPreservesDurableFactFields(t *testing.T) {
GiftEffectTypes: []string{"room_banner", "global_broadcast"}, GiftEffectTypes: []string{"room_banner", "global_broadcast"},
} }
records, err := buildRoomGiftSentRecords("room-gift-fact", 7, now, cmd, RoomMeta{VisibleRegionID: 8008}, true, []giftTargetBilling{{ records, err := buildRoomGiftSentRecords("room-gift-fact", 7, now, cmd, RoomMeta{VisibleRegionID: 8008}, true, 303, []giftTargetBilling{{
TargetUserID: 202, TargetUserID: 202,
CommandID: "cmd-gift-fact:202", CommandID: "cmd-gift-fact:202",
Billing: billing, Billing: billing,
@ -86,7 +86,7 @@ func TestBuildRoomGiftSentRecordsPreservesDurableFactFields(t *testing.T) {
if event.GetBillingReceiptId() != "receipt-target-202" || event.GetCoinSpent() != 300 || event.GetGiftValue() != 450 || event.GetTargetGiftValue() != 999 { if event.GetBillingReceiptId() != "receipt-target-202" || event.GetCoinSpent() != 300 || event.GetGiftValue() != 450 || event.GetTargetGiftValue() != 999 {
t.Fatalf("billing fields mismatch: %+v", &event) t.Fatalf("billing fields mismatch: %+v", &event)
} }
if event.GetVisibleRegionId() != 8008 || event.GetCountryId() != 86 || event.GetRegionId() != 9001 || event.GetCommandId() != "cmd-gift-fact:202" || !event.GetRoomLocked() { if event.GetVisibleRegionId() != 8008 || event.GetCountryId() != 86 || event.GetRegionId() != 9001 || event.GetCommandId() != "cmd-gift-fact:202" || !event.GetRoomLocked() || event.GetOwnerUserId() != 303 {
t.Fatalf("routing/stat fields mismatch: %+v", &event) t.Fatalf("routing/stat fields mismatch: %+v", &event)
} }
if event.GetGiftTypeCode() != "lucky" || event.GetCpRelationType() != "couple" || event.GetGiftName() != "Rose" { if event.GetGiftTypeCode() != "lucky" || event.GetCpRelationType() != "couple" || event.GetGiftName() != "Rose" {
@ -127,7 +127,7 @@ func TestBuildRoomGiftSentRecordsKeepsBatchDisplayFactsSplitPerTarget(t *testing
HeatValue: 100, HeatValue: 100,
} }
records, err := buildRoomGiftSentRecords("room-gift-batch-facts", 9, now, cmd, RoomMeta{}, false, []giftTargetBilling{ records, err := buildRoomGiftSentRecords("room-gift-batch-facts", 9, now, cmd, RoomMeta{}, false, 303, []giftTargetBilling{
{TargetUserID: 202, CommandID: "cmd-gift-batch-facts:202", Billing: billing}, {TargetUserID: 202, CommandID: "cmd-gift-batch-facts:202", Billing: billing},
{TargetUserID: 203, CommandID: "cmd-gift-batch-facts:203", Billing: billing}, {TargetUserID: 203, CommandID: "cmd-gift-batch-facts:203", Billing: billing},
}, nil) }, nil)
@ -142,7 +142,7 @@ func TestBuildRoomGiftSentRecordsKeepsBatchDisplayFactsSplitPerTarget(t *testing
if err := proto.Unmarshal(record.Envelope.GetBody(), &event); err != nil { if err := proto.Unmarshal(record.Envelope.GetBody(), &event); err != nil {
t.Fatalf("unmarshal batch RoomGiftSent failed: %v", err) t.Fatalf("unmarshal batch RoomGiftSent failed: %v", err)
} }
if event.GetTargetUserId() <= 0 || event.GetCommandId() == "" { if event.GetTargetUserId() <= 0 || event.GetCommandId() == "" || event.GetOwnerUserId() != 303 {
t.Fatalf("batch RoomGiftSent fact must keep per-target fields: %+v", &event) t.Fatalf("batch RoomGiftSent fact must keep per-target fields: %+v", &event)
} }
if event.GetDisplayMode() != "batch" { if event.GetDisplayMode() != "batch" {

View File

@ -29,6 +29,8 @@ type giftFlow struct {
cmd command.SendGift cmd command.SendGift
roomMeta RoomMeta roomMeta RoomMeta
// ownerUserID 与 roomLocked 在同一 Room Cell 串行区间读取RoomGiftSent 必须复用该快照。
ownerUserID int64
// roomLocked 在进入钱包/幸运礼物副作用前从同一个 Room Cell 快照固化,保证后续所有飘屏描述同一时刻的锁房状态。 // roomLocked 在进入钱包/幸运礼物副作用前从同一个 Room Cell 快照固化,保证后续所有飘屏描述同一时刻的锁房状态。
roomLocked bool roomLocked bool
rocketConfig RoomRocketConfig rocketConfig RoomRocketConfig
@ -112,6 +114,8 @@ func newGiftFlow(s *Service, ctx context.Context, req *roomv1.SendGiftRequest, o
} }
func (f *giftFlow) validateBeforeDebit(current *state.RoomState) error { func (f *giftFlow) validateBeforeDebit(current *state.RoomState) error {
// 普通礼物事实的房主身份属于 Room Cell 核心状态;异步 activity 不能用发送者或收礼人替代。
f.ownerUserID = current.OwnerUserID
// 新命令在 Room Cell 串行区间固化锁状态;恢复优先复用首次快照。历史 saga 没有该字段时才用当前 Cell 状态兼容补齐。 // 新命令在 Room Cell 串行区间固化锁状态;恢复优先复用首次快照。历史 saga 没有该字段时才用当前 Cell 状态兼容补齐。
if f.recovering && f.cmd.RoomLocked != nil { if f.recovering && f.cmd.RoomLocked != nil {
f.roomLocked = *f.cmd.RoomLocked f.roomLocked = *f.cmd.RoomLocked
@ -367,7 +371,7 @@ func (f *giftFlow) applyRoomState(now time.Time, current *state.RoomState, local
} }
func (f *giftFlow) buildMutationResult(now time.Time, current *state.RoomState) (mutationResult, []outbox.Record, error) { func (f *giftFlow) buildMutationResult(now time.Time, current *state.RoomState) (mutationResult, []outbox.Record, error) {
giftEvents, err := buildRoomGiftSentRecords(current.RoomID, current.Version, now, f.cmd, f.roomMeta, f.roomLocked, f.targetBillings, f.targetCurrentGiftValues) giftEvents, err := buildRoomGiftSentRecords(current.RoomID, current.Version, now, f.cmd, f.roomMeta, f.roomLocked, f.ownerUserID, f.targetBillings, f.targetCurrentGiftValues)
if err != nil { if err != nil {
return mutationResult{}, nil, err return mutationResult{}, nil, err
} }

View File

@ -116,7 +116,9 @@ func luckyGiftMetaForTarget(ctx context.Context, meta *roomv1.RequestMeta, cmd c
VisibleRegionId: roomMeta.VisibleRegionID, VisibleRegionId: roomMeta.VisibleRegionID,
// 锁房状态与 presence 校验取自同一个串行 Room Cell 快照lucky owner 只持久化展示事实,不能跨库回查房间。 // 锁房状态与 presence 校验取自同一个串行 Room Cell 快照lucky owner 只持久化展示事实,不能跨库回查房间。
RoomLocked: roomLocked, RoomLocked: roomLocked,
CountryId: cmd.SenderCountryID, // 幸运礼物使用原始送礼持久 RoomMeta 的房主身份;发送者、中奖人和异步回查结果都不能替代该快照。
OwnerUserId: roomMeta.OwnerUserID,
CountryId: cmd.SenderCountryID,
// 账号创建时间来自 gateway 的 user-service 快照并随首次命令持久化owner 只用它判断 // 账号创建时间来自 gateway 的 user-service 快照并随首次命令持久化owner 只用它判断
// 支付时账号是否已满完整历史窗口。0 表示旧命令/缺失事实,补偿大奖资格必须 fail-close。 // 支付时账号是否已满完整历史窗口。0 表示旧命令/缺失事实,补偿大奖资格必须 fail-close。
UserRegisteredAtMs: cmd.SenderRegisteredAtMS, UserRegisteredAtMs: cmd.SenderRegisteredAtMS,

View File

@ -130,7 +130,7 @@ func (s *Service) RobotSendGift(ctx context.Context, input RobotSendGiftInput) (
records, err := buildRoomGiftSentRecords(flow.cmd.RoomID(), snapshot.GetVersion(), now, flow.cmd, RoomMeta{ records, err := buildRoomGiftSentRecords(flow.cmd.RoomID(), snapshot.GetVersion(), now, flow.cmd, RoomMeta{
RoomID: flow.cmd.RoomID(), RoomID: flow.cmd.RoomID(),
VisibleRegionID: snapshot.GetVisibleRegionId(), VisibleRegionID: snapshot.GetVisibleRegionId(),
}, snapshot.GetLocked(), targetBillings, targetGiftValues) }, snapshot.GetLocked(), snapshot.GetOwnerUserId(), targetBillings, targetGiftValues)
if err != nil { if err != nil {
return nil, err return nil, err
} }

View File

@ -58,6 +58,7 @@ func (s *Service) HandleLuckyGiftOutboxEvent(ctx context.Context, envelope *luck
SenderPrettyDisplayUserId: drawn.GetSenderPrettyDisplayUserId(), SenderPrettyDisplayUserId: drawn.GetSenderPrettyDisplayUserId(),
VisibleRegionId: drawn.GetVisibleRegionId(), VisibleRegionId: drawn.GetVisibleRegionId(),
RoomLocked: drawn.GetRoomLocked(), RoomLocked: drawn.GetRoomLocked(),
OwnerUserId: drawn.GetOwnerUserId(),
CountryId: drawn.GetCountryId(), CountryId: drawn.GetCountryId(),
CoinSpent: drawn.GetCoinSpent(), CoinSpent: drawn.GetCoinSpent(),
RuleVersion: drawn.GetRuleVersion(), RuleVersion: drawn.GetRuleVersion(),

View File

@ -83,7 +83,7 @@ func TestHandleLuckyGiftOutboxEventPersistsOneDerivedRoomDisplayOutbox(t *testin
if err := proto.Unmarshal(record.Envelope.GetBody(), &body); err != nil { if err := proto.Unmarshal(record.Envelope.GetBody(), &body); err != nil {
t.Fatalf("decode derived RoomLuckyGiftDrawn failed: %v", err) t.Fatalf("decode derived RoomLuckyGiftDrawn failed: %v", err)
} }
if body.GetDrawId() != "lucky_draw_room_1" || body.GetSenderName() != "Sender" || body.GetMultiplierPpm() != luckygiftmq.LuckyGiftDisplayMinMultiplierPPM || body.GetRewardStatus() != "granted" || !body.GetRoomLocked() { if body.GetDrawId() != "lucky_draw_room_1" || body.GetSenderName() != "Sender" || body.GetMultiplierPpm() != luckygiftmq.LuckyGiftDisplayMinMultiplierPPM || body.GetRewardStatus() != "granted" || !body.GetRoomLocked() || body.GetOwnerUserId() != 323411852102995968 {
t.Fatalf("derived RoomLuckyGiftDrawn mismatch: %+v", &body) t.Fatalf("derived RoomLuckyGiftDrawn mismatch: %+v", &body)
} }
} }
@ -132,6 +132,7 @@ func luckyGiftDisplayEnvelope(t *testing.T, multiplierPPM int64) *luckygiftevent
SenderPrettyDisplayUserId: "VIP123456", SenderPrettyDisplayUserId: "VIP123456",
VisibleRegionId: 1, VisibleRegionId: 1,
RoomLocked: true, RoomLocked: true,
OwnerUserId: 323411852102995968,
CountryId: 86, CountryId: 86,
CoinSpent: 100, CoinSpent: 100,
RuleVersion: 3, RuleVersion: 3,

View File

@ -39,7 +39,7 @@ func (s *Service) MicUp(ctx context.Context, req *roomv1.MicUpRequest) (*roomv1.
} }
if current.MuteUsers[cmd.ActorUserID()] { if current.MuteUsers[cmd.ActorUserID()] {
// 禁言同时约束公屏和新上麦;校验必须落在 Room Cell 命令内,不能依赖客户端收到事件后自行拦截。 // 禁言同时约束公屏和新上麦;校验必须落在 Room Cell 命令内,不能依赖客户端收到事件后自行拦截。
return mutationResult{}, nil, xerr.New(xerr.PermissionDenied, "user is muted") return mutationResult{}, nil, xerr.New(xerr.RoomUserMuted, "user is muted")
} }
if seat, exists := current.SeatByUser(cmd.ActorUserID()); exists { if seat, exists := current.SeatByUser(cmd.ActorUserID()); exists {
@ -379,7 +379,7 @@ func (s *Service) ConfirmMicPublishing(ctx context.Context, req *roomv1.ConfirmM
} }
if current.MuteUsers[cmd.TargetUserID] { if current.MuteUsers[cmd.TargetUserID] {
// MicUp 后到 RTC 发流确认之间仍可能发生房间禁言;确认入口必须再次校验,不能复活已撤销的发言权限。 // MicUp 后到 RTC 发流确认之间仍可能发生房间禁言;确认入口必须再次校验,不能复活已撤销的发言权限。
return mutationResult{}, nil, xerr.New(xerr.PermissionDenied, "user is muted") return mutationResult{}, nil, xerr.New(xerr.RoomUserMuted, "user is muted")
} }
seat, exists := current.SeatByUser(cmd.TargetUserID) seat, exists := current.SeatByUser(cmd.TargetUserID)
@ -614,7 +614,7 @@ func (s *Service) SetMicMute(ctx context.Context, req *roomv1.SetMicMuteRequest)
} }
if !cmd.Muted && current.MuteUsers[cmd.TargetUserID] { if !cmd.Muted && current.MuteUsers[cmd.TargetUserID] {
// 房间禁言优先级高于用户自己的麦克风开关;解除房间禁言前禁止目标或管理员把 RTC 麦位重新打开。 // 房间禁言优先级高于用户自己的麦克风开关;解除房间禁言前禁止目标或管理员把 RTC 麦位重新打开。
return mutationResult{}, nil, xerr.New(xerr.PermissionDenied, "user is muted") return mutationResult{}, nil, xerr.New(xerr.RoomUserMuted, "user is muted")
} }
if seat.MicMuted == cmd.Muted { if seat.MicMuted == cmd.Muted {
return mutationResult{snapshot: current.ToProto(), seatNo: seat.SeatNo}, nil, nil return mutationResult{snapshot: current.ToProto(), seatNo: seat.SeatNo}, nil, nil

View File

@ -101,8 +101,8 @@ func TestMutedUserCannotSpeakOrMicUpUntilUnmuted(t *testing.T) {
if _, err := svc.MicUp(ctx, &roomv1.MicUpRequest{ if _, err := svc.MicUp(ctx, &roomv1.MicUpRequest{
Meta: rocketMeta(roomID, mutedUserID, "muted-mic-up"), Meta: rocketMeta(roomID, mutedUserID, "muted-mic-up"),
SeatNo: 2, SeatNo: 2,
}); !xerr.IsCode(err, xerr.PermissionDenied) { }); !xerr.IsCode(err, xerr.RoomUserMuted) {
t.Fatalf("muted user mic up must return permission denied: %v", err) t.Fatalf("muted user mic up must return room user muted: %v", err)
} }
if _, err := svc.MuteUser(ctx, &roomv1.MuteUserRequest{ if _, err := svc.MuteUser(ctx, &roomv1.MuteUserRequest{
@ -175,7 +175,7 @@ func TestMutedUserCannotSpeakOrMicUpUntilUnmuted(t *testing.T) {
RoomVersion: upResp.GetResult().GetRoomVersion(), RoomVersion: upResp.GetResult().GetRoomVersion(),
EventTimeMs: now.Now().UnixMilli(), EventTimeMs: now.Now().UnixMilli(),
Source: "client", Source: "client",
}); !xerr.IsCode(err, xerr.PermissionDenied) { }); !xerr.IsCode(err, xerr.RoomUserMuted) {
t.Fatalf("room mute between MicUp and RTC confirmation must reject publishing: %v", err) t.Fatalf("room mute between MicUp and RTC confirmation must reject publishing: %v", err)
} }
rtcEventResp, err := svc.ApplyRTCEvent(ctx, &roomv1.ApplyRTCEventRequest{ rtcEventResp, err := svc.ApplyRTCEvent(ctx, &roomv1.ApplyRTCEventRequest{

View File

@ -438,6 +438,8 @@ func (s *Service) applyRoomRocketGift(now time.Time, current *state.RoomState, c
RoomShortId: roomMeta.RoomShortID, RoomShortId: roomMeta.RoomShortID,
RocketCoverUrl: pending.CoverURL, RocketCoverUrl: pending.CoverURL,
ResetAtMs: pending.ResetAtMS, ResetAtMs: pending.ResetAtMS,
// 构造函数同时服务普通送礼和 RPS 礼物;在这里读取 Cell 房主才能保证两条点火链路不漏字段。
OwnerUserId: current.OwnerUserID,
} }
if rocket.CurrentLevel < roomRocketLevelCount { if rocket.CurrentLevel < roomRocketLevelCount {
nextLevel := rocket.CurrentLevel + 1 nextLevel := rocket.CurrentLevel + 1

View File

@ -1340,7 +1340,7 @@ func TestRoomRocketGiftOverflowAndImmediateNextLevel(t *testing.T) {
waitForRoomDirectIMCounts(t, directIM, map[string]int{"RoomRocketIgnited": 1}) waitForRoomDirectIMCounts(t, directIM, map[string]int{"RoomRocketIgnited": 1})
ignitedRecords := directIM.recordsByType("RoomRocketIgnited") ignitedRecords := directIM.recordsByType("RoomRocketIgnited")
var ignited roomeventsv1.RoomRocketIgnited var ignited roomeventsv1.RoomRocketIgnited
if len(ignitedRecords) != 1 || proto.Unmarshal(ignitedRecords[0].GetBody(), &ignited) != nil || !ignited.GetRoomLocked() { if len(ignitedRecords) != 1 || proto.Unmarshal(ignitedRecords[0].GetBody(), &ignited) != nil || !ignited.GetRoomLocked() || ignited.GetOwnerUserId() != ownerID {
t.Fatalf("rocket ignition must carry the Room Cell lock snapshot: records=%+v event=%+v", ignitedRecords, &ignited) t.Fatalf("rocket ignition must carry the Room Cell lock snapshot: records=%+v event=%+v", ignitedRecords, &ignited)
} }
if got := outboxEventCounts(t, ctx, repository)["RoomRocketIgnited"]; got != 1 { if got := outboxEventCounts(t, ctx, repository)["RoomRocketIgnited"]; got != 1 {

View File

@ -156,6 +156,7 @@ CREATE TABLE IF NOT EXISTS stat_social_user_day (
region_id BIGINT NOT NULL DEFAULT 0 COMMENT '事件发生时区域维度', region_id BIGINT NOT NULL DEFAULT 0 COMMENT '事件发生时区域维度',
user_role VARCHAR(16) NOT NULL DEFAULT 'user' COMMENT '当日用户身份快照user 或 host', user_role VARCHAR(16) NOT NULL DEFAULT 'user' COMMENT '当日用户身份快照user 或 host',
app_first_open TINYINT(1) NOT NULL DEFAULT 0 COMMENT '首次打开 App 去重标记', app_first_open TINYINT(1) NOT NULL DEFAULT 0 COMMENT '首次打开 App 去重标记',
profile_setup_view TINYINT(1) NOT NULL DEFAULT 0 COMMENT '填写资料页曝光去重标记',
registered_success TINYINT(1) NOT NULL DEFAULT 0 COMMENT '注册成功去重标记', registered_success TINYINT(1) NOT NULL DEFAULT 0 COMMENT '注册成功去重标记',
active_user TINYINT(1) NOT NULL DEFAULT 0 COMMENT '当日活跃去重标记', active_user TINYINT(1) NOT NULL DEFAULT 0 COMMENT '当日活跃去重标记',
room_join_success TINYINT(1) NOT NULL DEFAULT 0 COMMENT '进房成功去重标记', room_join_success TINYINT(1) NOT NULL DEFAULT 0 COMMENT '进房成功去重标记',

View File

@ -0,0 +1,73 @@
SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci;
SET time_zone = '+00:00';
-- 单个带默认值的标记列在 MySQL 8 使用 INSTANT 元数据变更,不重写用户日宽表。
-- 生产执行前仍需确认 information_schema 与目标 MySQL 版本。
SET @add_profile_setup_view := (
SELECT IF(
COUNT(*) = 0,
'ALTER TABLE stat_social_user_day ADD COLUMN profile_setup_view TINYINT(1) NOT NULL DEFAULT 0 COMMENT ''填写资料页曝光去重标记'' AFTER app_first_open, ALGORITHM=INSTANT',
'SELECT 1'
)
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'stat_social_user_day'
AND COLUMN_NAME = 'profile_setup_view'
);
PREPARE stmt FROM @add_profile_setup_view;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
-- profile_start 自 2026-07-09 客户端漏斗埋点上线后已经写入 statistics-service 原始事件表,
-- 但旧消费者尚未生成 Social 日投影。回灌固定从功能上线日开始,并同时生成 UTC/中国自然日两份;
-- FORCE INDEX 的等值 App+事件名和时间下界可使用 idx_app_tracking_event_window避免全表扫描。
-- 同一主体日的多次曝光由主键和 GREATEST 收敛,迁移重复执行不会重复计数。
SET @profile_start_from_ms = UNIX_TIMESTAMP('2026-07-09 00:00:00') * 1000;
SET @profile_start_to_ms = UNIX_TIMESTAMP(UTC_TIMESTAMP(6)) * 1000;
INSERT INTO stat_social_user_day (
app_code, stat_tz, stat_day, subject_key, user_id, device_id,
country_id, region_id, user_role, profile_setup_view, updated_at_ms
)
SELECT
event.app_code,
timezone_scope.stat_tz,
DATE(FROM_UNIXTIME((event.occurred_at_ms + timezone_scope.offset_ms) / 1000)),
CASE
WHEN event.user_id > 0 THEN CONCAT('u:', event.user_id)
ELSE CONCAT('d:', event.device_id)
END,
event.user_id,
event.device_id,
event.country_id,
event.region_id,
'user',
1,
event.received_at_ms
FROM app_tracking_events event FORCE INDEX (idx_app_tracking_event_window)
CROSS JOIN (
SELECT 'UTC' AS stat_tz, 0 AS offset_ms
UNION ALL
SELECT 'Asia/Shanghai' AS stat_tz, 28800000 AS offset_ms
) timezone_scope
WHERE event.app_code IN ('lalu', 'huwaa', 'fami')
AND event.event_name = 'profile_start'
AND event.occurred_at_ms >= @profile_start_from_ms
AND event.occurred_at_ms < @profile_start_to_ms
ON DUPLICATE KEY UPDATE
user_id = CASE WHEN VALUES(user_id) > 0 THEN VALUES(user_id) ELSE user_id END,
device_id = CASE WHEN VALUES(device_id) <> '' THEN VALUES(device_id) ELSE device_id END,
country_id = CASE WHEN VALUES(country_id) > 0 THEN VALUES(country_id) ELSE country_id END,
region_id = CASE WHEN VALUES(region_id) > 0 THEN VALUES(region_id) ELSE region_id END,
profile_setup_view = GREATEST(profile_setup_view, VALUES(profile_setup_view)),
updated_at_ms = GREATEST(updated_at_ms, VALUES(updated_at_ms));
SELECT ROW_COUNT() AS social_profile_setup_view_backfill_affected_rows;
SELECT
COUNT(*) AS social_profile_setup_view_column_count
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'stat_social_user_day'
AND COLUMN_NAME = 'profile_setup_view';

View File

@ -531,6 +531,38 @@ func TestSocialIdentityCanonicalizesAnonymousAndLoggedSession(t *testing.T) {
} }
} }
func TestProfileSetupViewMetricCanonicalizesAndDeduplicatesDailyExposure(t *testing.T) {
const day = "2026-07-23"
delta, ok := socialDeltaForAppTrackingEvent(AppTrackingEvent{
AppCode: "lalu",
EventName: "profile_start",
DeviceID: "device-profile",
OccurredAtMS: time.Date(2026, 7, 23, 0, 0, 0, 0, time.UTC).UnixMilli(),
}, statDayScope{tz: "Asia/Shanghai", day: day}, time.Now().UnixMilli())
if !ok || delta.ProfileSetupView != 1 || delta.Active != 0 {
t.Fatalf("profile_start delta mismatch: ok=%v delta=%+v", ok, delta)
}
rows := []socialRequirementUserDay{
{StatDay: day, Subject: "d:device-profile", DeviceID: "device-profile", ProfileSetupView: 1},
{StatDay: day, Subject: "u:123", UserID: 123, DirectUser: true, DeviceID: "device-profile", ProfileSetupView: 1},
}
canonical := canonicalizeSocialUserDays(rows, map[socialIdentityDeviceDayKey]int64{
{StatDay: day, DeviceID: "device-profile"}: 123,
}, 0, 0, nil)
if len(canonical) != 1 || canonical[0].ProfileSetupView != 1 {
t.Fatalf("profile setup exposure was not canonicalized once: %+v", canonical)
}
metrics := socialSectionMetrics(
SocialSectionNewUsers,
aggregateSocialRows(canonical, SocialSectionNewUsers, "all", "all", "all"),
nil,
)
if metrics["profile_setup_view_users"] != int64(1) {
t.Fatalf("profile setup exposure metric mismatch: %+v", metrics)
}
}
func TestSocialIdentityMarksConcurrentAccountSwitchAmbiguous(t *testing.T) { func TestSocialIdentityMarksConcurrentAccountSwitchAmbiguous(t *testing.T) {
ctx := context.Background() ctx := context.Background()
_, file, _, _ := runtime.Caller(0) _, file, _, _ := runtime.Caller(0)
@ -740,6 +772,9 @@ func TestQuerySocialRequirementsAggregatesP0ToP5(t *testing.T) {
for _, event := range []AppTrackingEvent{ for _, event := range []AppTrackingEvent{
{AppCode: "lalu", EventID: "social:first-open:1001", EventName: "app_first_open", UserID: user1, DeviceID: "dev-1001", CountryID: 86, RegionID: 210, Success: true, OccurredAtMS: start.UnixMilli()}, {AppCode: "lalu", EventID: "social:first-open:1001", EventName: "app_first_open", UserID: user1, DeviceID: "dev-1001", CountryID: 86, RegionID: 210, Success: true, OccurredAtMS: start.UnixMilli()},
{AppCode: "lalu", EventID: "social:first-open:1002", EventName: "app_first_open", UserID: user2, DeviceID: "dev-1002", CountryID: 86, RegionID: 210, Success: true, OccurredAtMS: start.UnixMilli()}, {AppCode: "lalu", EventID: "social:first-open:1002", EventName: "app_first_open", UserID: user2, DeviceID: "dev-1002", CountryID: 86, RegionID: 210, Success: true, OccurredAtMS: start.UnixMilli()},
{AppCode: "lalu", EventID: "social:profile-start:1001", EventName: "profile_start", UserID: user1, DeviceID: "dev-1001", CountryID: 86, RegionID: 210, Success: true, OccurredAtMS: start.Add(time.Minute).UnixMilli()},
{AppCode: "lalu", EventID: "social:profile-start:1001-repeat", EventName: "profile_start", UserID: user1, DeviceID: "dev-1001", CountryID: 86, RegionID: 210, Success: true, OccurredAtMS: start.Add(90 * time.Second).UnixMilli()},
{AppCode: "lalu", EventID: "social:profile-start:1002", EventName: "profile_start", UserID: user2, DeviceID: "dev-1002", CountryID: 86, RegionID: 210, Success: true, OccurredAtMS: start.Add(2 * time.Minute).UnixMilli()},
{AppCode: "lalu", EventID: "social:app-session:1001", EventName: "app_session", UserID: user1, DeviceID: "dev-1001", CountryID: 86, RegionID: 210, DurationMS: 600_000, Success: true, OccurredAtMS: start.Add(2 * time.Minute).UnixMilli()}, {AppCode: "lalu", EventID: "social:app-session:1001", EventName: "app_session", UserID: user1, DeviceID: "dev-1001", CountryID: 86, RegionID: 210, DurationMS: 600_000, Success: true, OccurredAtMS: start.Add(2 * time.Minute).UnixMilli()},
{AppCode: "lalu", EventID: "social:room-stay:1001", EventName: "room_stay", UserID: user1, DeviceID: "dev-1001", CountryID: 86, RegionID: 210, DurationMS: 300_000, Success: true, OccurredAtMS: start.Add(3 * time.Minute).UnixMilli()}, {AppCode: "lalu", EventID: "social:room-stay:1001", EventName: "room_stay", UserID: user1, DeviceID: "dev-1001", CountryID: 86, RegionID: 210, DurationMS: 300_000, Success: true, OccurredAtMS: start.Add(3 * time.Minute).UnixMilli()},
{AppCode: "lalu", EventID: "social:gift-panel:1001", EventName: "gift_panel_open", UserID: user1, DeviceID: "dev-1001", CountryID: 86, RegionID: 210, Success: true, OccurredAtMS: start.Add(4 * time.Minute).UnixMilli()}, {AppCode: "lalu", EventID: "social:gift-panel:1001", EventName: "gift_panel_open", UserID: user1, DeviceID: "dev-1001", CountryID: 86, RegionID: 210, Success: true, OccurredAtMS: start.Add(4 * time.Minute).UnixMilli()},
@ -827,6 +862,7 @@ func TestQuerySocialRequirementsAggregatesP0ToP5(t *testing.T) {
} }
newUsers := socialTestSection(t, result, SocialSectionNewUsers) newUsers := socialTestSection(t, result, SocialSectionNewUsers)
if socialMetricInt(t, newUsers.Total, "app_download_users") != 2 || if socialMetricInt(t, newUsers.Total, "app_download_users") != 2 ||
socialMetricInt(t, newUsers.Total, "profile_setup_view_users") != 2 ||
socialMetricInt(t, newUsers.Total, "registered_users") != 2 || socialMetricInt(t, newUsers.Total, "registered_users") != 2 ||
socialMetricInt(t, newUsers.Total, "new_host_users") != 1 || socialMetricInt(t, newUsers.Total, "new_host_users") != 1 ||
socialMetricInt(t, newUsers.Total, "new_normal_users") != 1 || socialMetricInt(t, newUsers.Total, "new_normal_users") != 1 ||
@ -839,6 +875,15 @@ func TestQuerySocialRequirementsAggregatesP0ToP5(t *testing.T) {
socialMetricInt(t, newUsers.Total, "new_user_recharge_usd_minor") != 2100 { socialMetricInt(t, newUsers.Total, "new_user_recharge_usd_minor") != 2100 {
t.Fatalf("P0 metrics mismatch: %+v", newUsers.Total.Metrics) t.Fatalf("P0 metrics mismatch: %+v", newUsers.Total.Metrics)
} }
profileSetupColumnFound := false
for _, column := range newUsers.Columns {
if column.Key == "profile_setup_view_users" {
profileSetupColumnFound = column.Label == "填写资料页曝光人数" && column.Type == "count"
}
}
if !profileSetupColumnFound {
t.Fatalf("P0 profile setup view column contract is missing: %+v", newUsers.Columns)
}
revenue := socialTestSection(t, result, SocialSectionRevenue) revenue := socialTestSection(t, result, SocialSectionRevenue)
// 文档口径:总充值 2100 = Google 500 + 三方(MiFaPay) 700 + 币商(finance verified) 900。 // 文档口径:总充值 2100 = Google 500 + 三方(MiFaPay) 700 + 币商(finance verified) 900。

View File

@ -195,7 +195,8 @@ func (r *Repository) Migrate(ctx context.Context) error {
app_code VARCHAR(32) NOT NULL, stat_tz VARCHAR(64) NOT NULL DEFAULT 'UTC', stat_day DATE NOT NULL, app_code VARCHAR(32) NOT NULL, stat_tz VARCHAR(64) NOT NULL DEFAULT 'UTC', stat_day DATE NOT NULL,
subject_key VARCHAR(160) NOT NULL, user_id BIGINT NOT NULL DEFAULT 0, device_id VARCHAR(128) NOT NULL DEFAULT '', subject_key VARCHAR(160) NOT NULL, user_id BIGINT NOT NULL DEFAULT 0, device_id VARCHAR(128) NOT NULL DEFAULT '',
country_id BIGINT NOT NULL DEFAULT 0, region_id BIGINT NOT NULL DEFAULT 0, user_role VARCHAR(16) NOT NULL DEFAULT 'user', country_id BIGINT NOT NULL DEFAULT 0, region_id BIGINT NOT NULL DEFAULT 0, user_role VARCHAR(16) NOT NULL DEFAULT 'user',
app_first_open TINYINT(1) NOT NULL DEFAULT 0, registered_success TINYINT(1) NOT NULL DEFAULT 0, app_first_open TINYINT(1) NOT NULL DEFAULT 0, profile_setup_view TINYINT(1) NOT NULL DEFAULT 0,
registered_success TINYINT(1) NOT NULL DEFAULT 0,
active_user TINYINT(1) NOT NULL DEFAULT 0, room_join_success TINYINT(1) NOT NULL DEFAULT 0, room_join_fail TINYINT(1) NOT NULL DEFAULT 0, active_user TINYINT(1) NOT NULL DEFAULT 0, room_join_success TINYINT(1) NOT NULL DEFAULT 0, room_join_fail TINYINT(1) NOT NULL DEFAULT 0,
mic_up_success TINYINT(1) NOT NULL DEFAULT 0, gift_sent TINYINT(1) NOT NULL DEFAULT 0, normal_gift_sent TINYINT(1) NOT NULL DEFAULT 0, mic_up_success TINYINT(1) NOT NULL DEFAULT 0, gift_sent TINYINT(1) NOT NULL DEFAULT 0, normal_gift_sent TINYINT(1) NOT NULL DEFAULT 0,
lucky_gift_sent TINYINT(1) NOT NULL DEFAULT 0, super_lucky_gift_sent TINYINT(1) NOT NULL DEFAULT 0, lucky_gift_sent TINYINT(1) NOT NULL DEFAULT 0, super_lucky_gift_sent TINYINT(1) NOT NULL DEFAULT 0,
@ -431,6 +432,8 @@ func (r *Repository) Migrate(ctx context.Context) error {
`ALTER TABLE stat_app_day_country ADD COLUMN mic_online_users BIGINT NOT NULL DEFAULT 0 AFTER mic_online_ms`, `ALTER TABLE stat_app_day_country ADD COLUMN mic_online_users BIGINT NOT NULL DEFAULT 0 AFTER mic_online_ms`,
`ALTER TABLE stat_game_day_country ADD COLUMN refund_coin BIGINT NOT NULL DEFAULT 0 AFTER payout_coin`, `ALTER TABLE stat_game_day_country ADD COLUMN refund_coin BIGINT NOT NULL DEFAULT 0 AFTER payout_coin`,
`ALTER TABLE stat_social_user_day ADD COLUMN room_online_ms BIGINT NOT NULL DEFAULT 0 AFTER room_stay_ms`, `ALTER TABLE stat_social_user_day ADD COLUMN room_online_ms BIGINT NOT NULL DEFAULT 0 AFTER room_stay_ms`,
// 单个带默认值的标记列使用 INSTANT 元数据变更,不在服务启动时重写 Social 用户日宽表。
`ALTER TABLE stat_social_user_day ADD COLUMN profile_setup_view TINYINT(1) NOT NULL DEFAULT 0 AFTER app_first_open, ALGORITHM=INSTANT`,
`ALTER TABLE stat_app_day_country ADD COLUMN region_id BIGINT NOT NULL DEFAULT 0 AFTER country_id`, `ALTER TABLE stat_app_day_country ADD COLUMN region_id BIGINT NOT NULL DEFAULT 0 AFTER country_id`,
`ALTER TABLE stat_user_day_activity ADD COLUMN region_id BIGINT NOT NULL DEFAULT 0 AFTER country_id`, `ALTER TABLE stat_user_day_activity ADD COLUMN region_id BIGINT NOT NULL DEFAULT 0 AFTER country_id`,
`ALTER TABLE stat_user_registration ADD COLUMN region_id BIGINT NOT NULL DEFAULT 0 AFTER country_id`, `ALTER TABLE stat_user_registration ADD COLUMN region_id BIGINT NOT NULL DEFAULT 0 AFTER country_id`,

View File

@ -107,6 +107,7 @@ type socialRequirementUserDay struct {
RegionID int64 RegionID int64
UserRole string UserRole string
AppFirstOpen int64 AppFirstOpen int64
ProfileSetupView int64
Registered int64 Registered int64
Active int64 Active int64
RoomJoinOK int64 RoomJoinOK int64
@ -155,6 +156,7 @@ type socialRequirementUserDay struct {
type socialRequirementAggregate struct { type socialRequirementAggregate struct {
AppFirstOpen int64 AppFirstOpen int64
ProfileSetupView int64
Registered int64 Registered int64
NewHostUsers int64 NewHostUsers int64
NewNormalUsers int64 NewNormalUsers int64
@ -385,7 +387,7 @@ func (r *Repository) querySocialUserDays(ctx context.Context, app string, statTZ
// 过滤会丢掉同一用户登录前的活跃,并让区域 DAU 再次出现 d/u 口径偏差。 // 过滤会丢掉同一用户登录前的活跃,并让区域 DAU 再次出现 d/u 口径偏差。
rows, err := r.db.QueryContext(ctx, ` rows, err := r.db.QueryContext(ctx, `
SELECT DATE_FORMAT(stat_day, '%Y-%m-%d'), subject_key, user_id, device_id, country_id, region_id, user_role, SELECT DATE_FORMAT(stat_day, '%Y-%m-%d'), subject_key, user_id, device_id, country_id, region_id, user_role,
app_first_open, registered_success, active_user, room_join_success, room_join_fail, app_first_open, profile_setup_view, registered_success, active_user, room_join_success, room_join_fail,
mic_up_success, gift_sent, normal_gift_sent, lucky_gift_sent, super_lucky_gift_sent, mic_up_success, gift_sent, normal_gift_sent, lucky_gift_sent, super_lucky_gift_sent,
recharge_user, first_recharge_user, repeat_recharge_user, game_bet_user, self_game_bet_user, recharge_user, first_recharge_user, repeat_recharge_user, game_bet_user, self_game_bet_user,
probability_game_bet_user, chat_sent, follow_user, follow_room, gift_panel_open, probability_game_panel_open, probability_game_bet_user, chat_sent, follow_user, follow_room, gift_panel_open, probability_game_panel_open,
@ -407,7 +409,7 @@ func (r *Repository) querySocialUserDays(ctx context.Context, app string, statTZ
for rows.Next() { for rows.Next() {
var item socialRequirementUserDay var item socialRequirementUserDay
if err := rows.Scan(&item.StatDay, &item.Subject, &item.UserID, &item.DeviceID, &item.CountryID, &item.RegionID, &item.UserRole, if err := rows.Scan(&item.StatDay, &item.Subject, &item.UserID, &item.DeviceID, &item.CountryID, &item.RegionID, &item.UserRole,
&item.AppFirstOpen, &item.Registered, &item.Active, &item.RoomJoinOK, &item.RoomJoinNG, &item.AppFirstOpen, &item.ProfileSetupView, &item.Registered, &item.Active, &item.RoomJoinOK, &item.RoomJoinNG,
&item.MicUp, &item.Gift, &item.NormalGift, &item.LuckyGift, &item.SuperGift, &item.MicUp, &item.Gift, &item.NormalGift, &item.LuckyGift, &item.SuperGift,
&item.Recharge, &item.FirstRecharge, &item.RepeatRecharge, &item.GameBet, &item.SelfGameBet, &item.Recharge, &item.FirstRecharge, &item.RepeatRecharge, &item.GameBet, &item.SelfGameBet,
&item.ProbabilityBet, &item.Chat, &item.FollowUser, &item.FollowRoom, &item.GiftPanel, &item.ProbabilityPanel, &item.ProbabilityBet, &item.Chat, &item.FollowUser, &item.FollowRoom, &item.GiftPanel, &item.ProbabilityPanel,
@ -682,6 +684,7 @@ func mergeSocialRequirementUserDay(current, next socialRequirementUserDay) socia
// canonicalizeSocialUserDays replaces this flag with the distinct known // canonicalizeSocialUserDays replaces this flag with the distinct known
// device count after all rows merge; MAX is only the empty-device fallback. // device count after all rows merge; MAX is only the empty-device fallback.
current.AppFirstOpen = maxSocialFlag(current.AppFirstOpen, next.AppFirstOpen) current.AppFirstOpen = maxSocialFlag(current.AppFirstOpen, next.AppFirstOpen)
current.ProfileSetupView = maxSocialFlag(current.ProfileSetupView, next.ProfileSetupView)
current.Registered = maxSocialFlag(current.Registered, next.Registered) current.Registered = maxSocialFlag(current.Registered, next.Registered)
current.Active = maxSocialFlag(current.Active, next.Active) current.Active = maxSocialFlag(current.Active, next.Active)
current.RoomJoinOK = maxSocialFlag(current.RoomJoinOK, next.RoomJoinOK) current.RoomJoinOK = maxSocialFlag(current.RoomJoinOK, next.RoomJoinOK)
@ -786,6 +789,9 @@ func aggregateSocialRows(rows []socialRequirementUserDay, section string, role s
// 下载/首次打开是设备口径;身份归并只消除用户行为的 d/u 重复, // 下载/首次打开是设备口径;身份归并只消除用户行为的 d/u 重复,
// 不能把同一账号在两台设备上的两次安装压成 1。 // 不能把同一账号在两台设备上的两次安装压成 1。
agg.AppFirstOpen += normalizeID(row.AppFirstOpen) agg.AppFirstOpen += normalizeID(row.AppFirstOpen)
// 资料页曝光按 canonical 主体日去重:登录用户按 user_id注册前匿名流程按 device_id
// 同一客户端会话完成登录后,查询阶段会把 d:/u: 两行合并,避免一次曝光重复计数。
agg.ProfileSetupView += flagValue(row.ProfileSetupView)
agg.Registered += flagValue(row.Registered) agg.Registered += flagValue(row.Registered)
if flagValue(row.Registered) > 0 && normalizeSocialUserRole(row.UserRole) == "host" { if flagValue(row.Registered) > 0 && normalizeSocialUserRole(row.UserRole) == "host" {
agg.NewHostUsers++ agg.NewHostUsers++
@ -1118,6 +1124,7 @@ func socialSectionMetrics(section string, agg socialRequirementAggregate, retent
default: default:
out := map[string]any{ out := map[string]any{
"app_download_users": agg.AppFirstOpen, "app_download_users": agg.AppFirstOpen,
"profile_setup_view_users": agg.ProfileSetupView,
"registered_users": agg.Registered, "registered_users": agg.Registered,
"new_host_users": agg.NewHostUsers, "new_host_users": agg.NewHostUsers,
"new_normal_users": agg.NewNormalUsers, "new_normal_users": agg.NewNormalUsers,
@ -1312,6 +1319,7 @@ func socialRequirementMetricDefinitions(section string) []MetricDefinition {
default: default:
return []MetricDefinition{ return []MetricDefinition{
{Metric: "app_download_users", Definition: "客户端 app_first_open 去重人数;不代表应用商店真实下载量。"}, {Metric: "app_download_users", Definition: "客户端 app_first_open 去重人数;不代表应用商店真实下载量。"},
{Metric: "profile_setup_view_users", Definition: "进入填写资料页的新用户人数:客户端 profile_start 事件按 canonical 用户或设备主体、统计自然日去重。"},
// registered_users 保留历史字段兼容,业务展示统一为“新增用户”;资格和幂等边界由 UserRegistered 生产、消费链路保证。 // registered_users 保留历史字段兼容,业务展示统一为“新增用户”;资格和幂等边界由 UserRegistered 生产、消费链路保证。
{Metric: "registered_users", Definition: "注册成功的新用户人数:仅统计资料完成后由 user-service 发布的 UserRegistered 服务端事实按用户去重未完成资料、quick_account、game_robot 不计MQ 重投不重复。"}, {Metric: "registered_users", Definition: "注册成功的新用户人数:仅统计资料完成后由 user-service 发布的 UserRegistered 服务端事实按用户去重未完成资料、quick_account、game_robot 不计MQ 重投不重复。"},
{Metric: "new_user_d1_retention_rate", Definition: "新普通用户次日活跃留存。"}, {Metric: "new_user_d1_retention_rate", Definition: "新普通用户次日活跃留存。"},
@ -1422,6 +1430,7 @@ func socialRequirementColumns(section string) []SocialRequirementColumn {
default: default:
return []SocialRequirementColumn{ return []SocialRequirementColumn{
{Key: "app_download_users", Label: "下载APP人数", Type: "count", Tooltip: "客户端 app_first_open 去重人数"}, {Key: "app_download_users", Label: "下载APP人数", Type: "count", Tooltip: "客户端 app_first_open 去重人数"},
{Key: "profile_setup_view_users", Label: "填写资料页曝光人数", Type: "count", Tooltip: "进入填写资料页的新用户人数;同一用户或设备同一天重复进入只计一次"},
{Key: "registered_users", Label: "新增用户", Type: "count", Tooltip: "注册成功的新用户人数:资料完成后产生 UserRegistered 服务端事实并按用户去重未完成资料、quick_account、game_robot 不计MQ 重投不重复"}, {Key: "registered_users", Label: "新增用户", Type: "count", Tooltip: "注册成功的新用户人数:资料完成后产生 UserRegistered 服务端事实并按用户去重未完成资料、quick_account、game_robot 不计MQ 重投不重复"},
{Key: "new_host_users", Label: "新主播用户", Type: "count"}, {Key: "new_host_users", Label: "新主播用户", Type: "count"},
{Key: "new_normal_users", Label: "新普通用户", Type: "count"}, {Key: "new_normal_users", Label: "新普通用户", Type: "count"},

View File

@ -20,6 +20,7 @@ type socialUserDayDelta struct {
UserRole string UserRole string
UpdatedAt int64 UpdatedAt int64
FirstOpen int64 FirstOpen int64
ProfileSetupView int64
Registered int64 Registered int64
Active int64 Active int64
RoomJoinOK int64 RoomJoinOK int64
@ -99,6 +100,7 @@ func applySocialUserDayDelta(ctx context.Context, tx *sql.Tx, delta socialUserDa
_, err := tx.ExecContext(ctx, ` _, err := tx.ExecContext(ctx, `
UPDATE stat_social_user_day UPDATE stat_social_user_day
SET app_first_open = GREATEST(app_first_open, ?), SET app_first_open = GREATEST(app_first_open, ?),
profile_setup_view = GREATEST(profile_setup_view, ?),
registered_success = GREATEST(registered_success, ?), registered_success = GREATEST(registered_success, ?),
active_user = GREATEST(active_user, ?), active_user = GREATEST(active_user, ?),
room_join_success = GREATEST(room_join_success, ?), room_join_success = GREATEST(room_join_success, ?),
@ -145,7 +147,7 @@ func applySocialUserDayDelta(ctx context.Context, tx *sql.Tx, delta socialUserDa
output_coin = output_coin + ?, output_coin = output_coin + ?,
updated_at_ms = ? updated_at_ms = ?
WHERE app_code = ? AND stat_tz = ? AND stat_day = ? AND subject_key = ? WHERE app_code = ? AND stat_tz = ? AND stat_day = ? AND subject_key = ?
`, flagValue(delta.FirstOpen), flagValue(delta.Registered), flagValue(delta.Active), flagValue(delta.RoomJoinOK), flagValue(delta.RoomJoinNG), `, flagValue(delta.FirstOpen), flagValue(delta.ProfileSetupView), flagValue(delta.Registered), flagValue(delta.Active), flagValue(delta.RoomJoinOK), flagValue(delta.RoomJoinNG),
flagValue(delta.MicUp), flagValue(delta.Gift), flagValue(delta.NormalGift), flagValue(delta.LuckyGift), flagValue(delta.SuperGift), flagValue(delta.MicUp), flagValue(delta.Gift), flagValue(delta.NormalGift), flagValue(delta.LuckyGift), flagValue(delta.SuperGift),
flagValue(delta.Recharge), flagValue(delta.FirstRecharge), flagValue(delta.RepeatRecharge), flagValue(delta.GameBet), flagValue(delta.SelfGameBet), flagValue(delta.Recharge), flagValue(delta.FirstRecharge), flagValue(delta.RepeatRecharge), flagValue(delta.GameBet), flagValue(delta.SelfGameBet),
flagValue(delta.ProbabilityBet), flagValue(delta.Chat), flagValue(delta.FollowUser), flagValue(delta.FollowRoom), flagValue(delta.GiftPanel), flagValue(delta.ProbabilityBet), flagValue(delta.Chat), flagValue(delta.FollowUser), flagValue(delta.FollowRoom), flagValue(delta.GiftPanel),
@ -216,6 +218,10 @@ func socialDeltaForAppTrackingEvent(event AppTrackingEvent, scope statDayScope,
case "app_first_open": case "app_first_open":
delta.FirstOpen = 1 delta.FirstOpen = 1
delta.Active = 1 delta.Active = 1
case "profile_start":
// Flutter 仅在未完成资料的新用户真正进入 profile_setup 页面后上报;
// 用户退出重进可能重复上报,因此读模型只保留主体日级曝光标记。
delta.ProfileSetupView = 1
case "home_view": case "home_view":
delta.Active = 1 delta.Active = 1
case "app_session": case "app_session":

View File

@ -71,6 +71,8 @@ type RedPacketCreateCommand struct {
PacketCount int32 PacketCount int32
// RoomLocked 是 gateway 从 room-service snapshot 读取的展示快照,只随红包资金事实透传。 // RoomLocked 是 gateway 从 room-service snapshot 读取的展示快照,只随红包资金事实透传。
RoomLocked bool RoomLocked bool
// OwnerUserID 与 RoomLocked 来自同一份房间快照wallet 不解释房主权限,只随已提交红包事实透传。
OwnerUserID int64
} }
type RedPacketCreateReceipt struct { type RedPacketCreateReceipt struct {

View File

@ -27,6 +27,7 @@ type redPacketMetadata struct {
RoomID string `json:"room_id"` RoomID string `json:"room_id"`
RegionID int64 `json:"region_id"` RegionID int64 `json:"region_id"`
RoomLocked bool `json:"room_locked"` RoomLocked bool `json:"room_locked"`
OwnerUserID int64 `json:"owner_user_id,omitempty"`
PacketType string `json:"packet_type"` PacketType string `json:"packet_type"`
TotalAmount int64 `json:"total_amount"` TotalAmount int64 `json:"total_amount"`
PacketCount int32 `json:"packet_count"` PacketCount int32 `json:"packet_count"`

View File

@ -90,6 +90,7 @@ func (r *Repository) CreateRedPacket(ctx context.Context, command ledger.RedPack
RoomID: command.RoomID, RoomID: command.RoomID,
RegionID: command.RegionID, RegionID: command.RegionID,
RoomLocked: command.RoomLocked, RoomLocked: command.RoomLocked,
OwnerUserID: command.OwnerUserID,
PacketType: command.PacketType, PacketType: command.PacketType,
TotalAmount: command.TotalAmount, TotalAmount: command.TotalAmount,
PacketCount: command.PacketCount, PacketCount: command.PacketCount,
@ -228,6 +229,7 @@ func redPacketCreatedEvent(transactionID string, commandID string, metadata redP
"room_id": metadata.RoomID, "room_id": metadata.RoomID,
"region_id": metadata.RegionID, "region_id": metadata.RegionID,
"room_locked": metadata.RoomLocked, "room_locked": metadata.RoomLocked,
"owner_user_id": metadata.OwnerUserID,
"total_amount": metadata.TotalAmount, "total_amount": metadata.TotalAmount,
"packet_count": metadata.PacketCount, "packet_count": metadata.PacketCount,
"remaining_amount": metadata.RemainingAmount, "remaining_amount": metadata.RemainingAmount,
@ -242,7 +244,7 @@ func redPacketCreatedEvent(transactionID string, commandID string, metadata redP
} }
func redPacketCreateRequestHash(command ledger.RedPacketCreateCommand) string { func redPacketCreateRequestHash(command ledger.RedPacketCreateCommand) string {
// room_locked 是非资金展示快照,不进入幂等冲突哈希:未知结果重试时房间可能已改密,首笔已提交事实仍应原样返回 // room_locked/owner_user_id 是非资金展示快照,不进入幂等冲突哈希:未知结果重试必须返回首笔已提交事实,不能因展示字段变化制造账务冲突
return stableHash(fmt.Sprintf("red_packet_create|%s|%s|%d|%s|%d|%s|%d|%d", return stableHash(fmt.Sprintf("red_packet_create|%s|%s|%d|%s|%d|%s|%d|%d",
appcode.Normalize(command.AppCode), appcode.Normalize(command.AppCode),
command.CommandID, command.CommandID,

View File

@ -14,6 +14,7 @@ func TestRedPacketCreatedEventCarriesDisplayFacts(t *testing.T) {
RoomID: "9001", RoomID: "9001",
RegionID: 310, RegionID: 310,
RoomLocked: true, RoomLocked: true,
OwnerUserID: 323411852102995968,
TotalAmount: 15000, TotalAmount: 15000,
PacketCount: 5, PacketCount: 5,
RemainingAmount: 15000, RemainingAmount: 15000,
@ -37,4 +38,7 @@ func TestRedPacketCreatedEventCarriesDisplayFacts(t *testing.T) {
if payload["room_locked"] != true { if payload["room_locked"] != true {
t.Fatalf("red packet created payload must carry room lock snapshot: %+v", payload) t.Fatalf("red packet created payload must carry room lock snapshot: %+v", payload)
} }
if payload["owner_user_id"] != int64(323411852102995968) {
t.Fatalf("red packet created payload must carry owner snapshot: %+v", payload)
}
} }

View File

@ -56,6 +56,7 @@ func (s *Server) CreateRedPacket(ctx context.Context, req *walletv1.CreateRedPac
TotalAmount: req.GetTotalAmount(), TotalAmount: req.GetTotalAmount(),
PacketCount: req.GetPacketCount(), PacketCount: req.GetPacketCount(),
RoomLocked: req.GetRoomLocked(), RoomLocked: req.GetRoomLocked(),
OwnerUserID: req.GetOwnerUserId(),
}) })
if err != nil { if err != nil {
return nil, xerr.ToGRPCError(err) return nil, xerr.ToGRPCError(err)