diff --git a/api/proto/events/luckygift/v1/events.pb.go b/api/proto/events/luckygift/v1/events.pb.go index 352dc33f..ebe1791d 100644 --- a/api/proto/events/luckygift/v1/events.pb.go +++ b/api/proto/events/luckygift/v1/events.pb.go @@ -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"` 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 锁状态;下游不能异步回查当前房间再改写历史事实。 - 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 sizeCache protoimpl.SizeCache } @@ -376,6 +378,13 @@ func (x *LuckyGiftDrawn) GetRoomLocked() bool { 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 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" + "\adraw_id\x18\x04 \x01(\tR\x06drawId\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" + "\adraw_id\x18\x01 \x01(\tR\x06drawId\x12\x1d\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" + "\x14reward_granted_at_ms\x18\x1b \x01(\x03R\x11rewardGrantedAtMs\x12\x1f\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 ( file_proto_events_luckygift_v1_events_proto_rawDescOnce sync.Once diff --git a/api/proto/events/luckygift/v1/events.proto b/api/proto/events/luckygift/v1/events.proto index 2f61781b..753d889b 100644 --- a/api/proto/events/luckygift/v1/events.proto +++ b/api/proto/events/luckygift/v1/events.proto @@ -53,4 +53,6 @@ message LuckyGiftDrawn { int64 reward_granted_at_ms = 27; // room_locked 是 room-service 在原始送礼命令内固化的 Room Cell 锁状态;下游不能异步回查当前房间再改写历史事实。 bool room_locked = 28; + // owner_user_id 是原始送礼命令内固化的房主快照,只用于跨房展示交互,不参与抽奖或返奖。 + int64 owner_user_id = 29; } diff --git a/api/proto/events/room/v1/events.pb.go b/api/proto/events/room/v1/events.pb.go index 2c33a499..7218d6f9 100644 --- a/api/proto/events/room/v1/events.pb.go +++ b/api/proto/events/room/v1/events.pb.go @@ -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"` // 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"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + // owner_user_id 固化送礼命令执行时的 Room Cell 房主;跨房飘屏只能用它优化密码框,JoinRoom 仍做最终权限校验。 + 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() { @@ -1997,6 +1999,13 @@ func (x *RoomGiftSent) GetSenderGiftBroadcastAnimationUrl() string { return "" } +func (x *RoomGiftSent) GetOwnerUserId() int64 { + if x != nil { + return x.OwnerUserId + } + return 0 +} + // RoomGiftBatchLuckyResult 是批量送礼展示事件内的逐目标幸运礼物结果快照。 // 它只进入房间 IM 展示,不作为 activity 中奖事实,也不携带送礼人余额。 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"` RewardGrantedAtMs int64 `protobuf:"varint,26,opt,name=reward_granted_at_ms,json=rewardGrantedAtMs,proto3" json:"reward_granted_at_ms,omitempty"` // 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 sizeCache protoimpl.SizeCache } @@ -2777,6 +2788,13 @@ func (x *RoomLuckyGiftDrawn) GetRoomLocked() bool { return false } +func (x *RoomLuckyGiftDrawn) GetOwnerUserId() int64 { + if x != nil { + return x.OwnerUserId + } + return 0 +} + // RoomRobotLuckyGiftDrawn 是机器人房间专用幸运礼物展示事件。 // 它复用客户端 lucky_gift_drawn 表现协议,但不代表真实奖池抽奖和钱包返奖事实。 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"` ResetAtMs int64 `protobuf:"varint,14,opt,name=reset_at_ms,json=resetAtMs,proto3" json:"reset_at_ms,omitempty"` // 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 sizeCache protoimpl.SizeCache } @@ -3448,6 +3468,13 @@ func (x *RoomRocketIgnited) GetRoomLocked() bool { return false } +func (x *RoomRocketIgnited) GetOwnerUserId() int64 { + if x != nil { + return x.OwnerUserId + } + return 0 +} + // RoomRocketLaunched 表达火箭已经发射,在线用户名单按发射瞬间 Room Cell presence 结算。 type RoomRocketLaunched struct { 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" + "\x10RoomUserUnbanned\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" + "\x0esender_user_id\x18\x01 \x01(\x03R\fsenderUserId\x12$\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" + "roomLocked\x12(\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" + "\aenabled\x18\x01 \x01(\bR\aenabled\x12\x17\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" + "\x11visible_region_id\x18\x16 \x01(\x03R\x0fvisibleRegionId\x12(\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" + "\adraw_id\x18\x01 \x01(\tR\x06drawId\x12\x1d\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" + "\x14reward_granted_at_ms\x18\x1a \x01(\x03R\x11rewardGrantedAtMs\x12\x1f\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" + "\adraw_id\x18\x01 \x01(\tR\x06drawId\x12\x1d\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" + "\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" + "\trocket_id\x18\x01 \x01(\tR\brocketId\x12\x14\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" + "\vreset_at_ms\x18\x0e \x01(\x03R\tresetAtMs\x12\x1f\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" + "\trocket_id\x18\x01 \x01(\tR\brocketId\x12\x14\n" + "\x05level\x18\x02 \x01(\x05R\x05level\x12\x1d\n" + diff --git a/api/proto/events/room/v1/events.proto b/api/proto/events/room/v1/events.proto index eeb21fb9..e5240565 100644 --- a/api/proto/events/room/v1/events.proto +++ b/api/proto/events/room/v1/events.proto @@ -254,6 +254,8 @@ message RoomGiftSent { int32 sender_vip_level = 33; // sender_gift_broadcast_animation_url 是送礼入口从有效 gift_tray_skin 权益固化的动效 URL;下游禁止异步回查当前 VIP 覆盖历史事实。 string sender_gift_broadcast_animation_url = 34; + // owner_user_id 固化送礼命令执行时的 Room Cell 房主;跨房飘屏只能用它优化密码框,JoinRoom 仍做最终权限校验。 + int64 owner_user_id = 35; } // RoomGiftBatchLuckyResult 是批量送礼展示事件内的逐目标幸运礼物结果快照。 @@ -356,6 +358,8 @@ message RoomLuckyGiftDrawn { int64 reward_granted_at_ms = 26; // room_locked 来自 lucky owner 事实中的送礼时房间快照,仅用于客户端点击飘屏前决定是否先收集密码。 bool room_locked = 27; + // owner_user_id 与 room_locked 来自同一次原始送礼快照,禁止用中奖人、送礼人或异步房间查询替代。 + int64 owner_user_id = 28; } // RoomRobotLuckyGiftDrawn 是机器人房间专用幸运礼物展示事件。 @@ -437,6 +441,8 @@ message RoomRocketIgnited { int64 reset_at_ms = 14; // room_locked 固化点火礼物在 Room Cell 串行执行时的锁房状态,跨房点击不能异步回查后覆盖该次展示事实。 bool room_locked = 15; + // owner_user_id 固化点火瞬间的 Room Cell 房主,火箭贡献者和点火人都不能替代该身份。 + int64 owner_user_id = 16; } // RoomRocketLaunched 表达火箭已经发射,在线用户名单按发射瞬间 Room Cell presence 结算。 diff --git a/api/proto/luckygift/v1/luckygift.pb.go b/api/proto/luckygift/v1/luckygift.pb.go index 456376c8..76a24e26 100644 --- a/api/proto/luckygift/v1/luckygift.pb.go +++ b/api/proto/luckygift/v1/luckygift.pb.go @@ -134,7 +134,9 @@ type LuckyGiftMeta struct { // 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"` // 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 sizeCache protoimpl.SizeCache } @@ -337,6 +339,13 @@ func (x *LuckyGiftMeta) GetRoomLocked() bool { return false } +func (x *LuckyGiftMeta) GetOwnerUserId() int64 { + if x != nil { + return x.OwnerUserId + } + return 0 +} + type LuckyGiftRuleTier struct { state protoimpl.MessageState `protogen:"open.v1"` 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" + "\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" + "\x04meta\x18\x01 \x01(\v2\x1f.hyapp.luckygift.v1.RequestMetaR\x04meta\x12\x1d\n" + "\n" + @@ -5679,7 +5688,8 @@ const file_proto_luckygift_v1_luckygift_proto_rawDesc = "" + "\x11gift_income_coins\x18\x16 \x01(\x03R\x0fgiftIncomeCoins\x121\n" + "\x15user_registered_at_ms\x18\x17 \x01(\x03R\x12userRegisteredAtMs\x12\x1f\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" + "\x05stage\x18\x01 \x01(\tR\x05stage\x12\x17\n" + "\atier_id\x18\x02 \x01(\tR\x06tierId\x12%\n" + diff --git a/api/proto/luckygift/v1/luckygift.proto b/api/proto/luckygift/v1/luckygift.proto index b7ce88c5..dddb5587 100644 --- a/api/proto/luckygift/v1/luckygift.proto +++ b/api/proto/luckygift/v1/luckygift.proto @@ -49,6 +49,8 @@ message LuckyGiftMeta { int64 user_registered_at_ms = 23; // room_locked 由 Room Cell 在送礼命令串行执行期间读取,只进入中奖展示事实,不参与抽奖或账务计算。 bool room_locked = 24; + // owner_user_id 与 room_locked 在同一 Room Cell 串行区间固化;lucky owner 只持久化并透传该展示快照。 + int64 owner_user_id = 25; } message LuckyGiftRuleTier { diff --git a/api/proto/wallet/v1/wallet.pb.go b/api/proto/wallet/v1/wallet.pb.go index 80f4fd62..13add8c3 100644 --- a/api/proto/wallet/v1/wallet.pb.go +++ b/api/proto/wallet/v1/wallet.pb.go @@ -24067,7 +24067,9 @@ type CreateRedPacketRequest struct { 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"` // 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 sizeCache protoimpl.SizeCache } @@ -24172,6 +24174,13 @@ func (x *CreateRedPacketRequest) GetRoomLocked() bool { return false } +func (x *CreateRedPacketRequest) GetOwnerUserId() int64 { + if x != nil { + return x.OwnerUserId + } + return 0 +} + type CreateRedPacketResponse struct { state protoimpl.MessageState `protogen:"open.v1"` 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" + "\x1dUpdateRedPacketConfigResponse\x128\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" + "\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" + "\vroom_locked\x18\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" + "\x06packet\x18\x01 \x01(\v2\x1a.hyapp.wallet.v1.RedPacketR\x06packet\x12#\n" + "\rbalance_after\x18\x02 \x01(\x03R\fbalanceAfter\x12$\n" + diff --git a/api/proto/wallet/v1/wallet.proto b/api/proto/wallet/v1/wallet.proto index 9fd15755..021290f9 100644 --- a/api/proto/wallet/v1/wallet.proto +++ b/api/proto/wallet/v1/wallet.proto @@ -2807,6 +2807,8 @@ message CreateRedPacketRequest { int32 packet_count = 9; // room_locked 由 gateway 从本次发红包前读取的 room-service snapshot 固化;wallet 只随资金事实透传。 bool room_locked = 10; + // owner_user_id 与 room_locked 来自同一份 room snapshot,只用于红包跨房入口的密码框交互。 + int64 owner_user_id = 11; } message CreateRedPacketResponse { diff --git a/docs/数据大屏统计口径.md b/docs/数据大屏统计口径.md index 6f192799..f6344d1c 100644 --- a/docs/数据大屏统计口径.md +++ b/docs/数据大屏统计口径.md @@ -20,6 +20,7 @@ | `start_ms` / `end_ms` | 查询时间 | 本次查询的毫秒时间范围。 | | `country_id` / `region_id` | 国家/区域 | 聚合维度;未筛选国家时返回国家分解。 | | `new_users` | 新增用户 | `UserRegistered` 注册 cohort 去重用户数。 | +| `profile_setup_view_users` | 填写资料页曝光人数 | Flutter 在未完成资料的新用户真正进入 `profile_setup` 页面后上报 `profile_start`;Social 用户日读模型按 canonical 用户或设备主体、统计自然日去重。 | | `active_users` | 活跃用户 | `stat_social_user_day.active_user` 经设备日级 canonical 身份归并后的用户日去重数;多日汇总为活跃人天。 | | `paid_users` | 付费用户 | `WalletRechargeRecorded` 写入 `stat_recharge_day_payers` 后按用户去重。 | | `new_paid_users` | 新增付费用户 | 付费用户中注册 cohort 也落在查询周期内的用户数。 | diff --git a/pkg/xerr/catalog.go b/pkg/xerr/catalog.go index be7c5141..c802a867 100644 --- a/pkg/xerr/catalog.go +++ b/pkg/xerr/catalog.go @@ -27,6 +27,7 @@ var catalog = map[Code]Spec{ NotFound: spec(codes.NotFound, httpStatusNotFound, NotFound, "not found"), Conflict: spec(codes.FailedPrecondition, httpStatusConflict, Conflict, "request cannot be completed in current state"), 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( codes.FailedPrecondition, httpStatusConflict, diff --git a/pkg/xerr/errors.go b/pkg/xerr/errors.go index d9d41240..feb016c8 100644 --- a/pkg/xerr/errors.go +++ b/pkg/xerr/errors.go @@ -19,6 +19,8 @@ const ( Conflict Code = "CONFLICT" // RoomClosed 表示房间已被后台关闭,客户端不能再进入。 RoomClosed Code = "ROOM_CLOSED" + // RoomUserMuted 表示房间禁言正在阻止用户上麦、发流或重新打开麦克风。 + RoomUserMuted Code = "ROOM_USER_MUTED" // RoomRPSChallengeTaken 表示房内猜拳挑战已被其他用户接单,当前用户不能再重复应战。 RoomRPSChallengeTaken Code = "ROOM_RPS_CHALLENGE_TAKEN" // RoomRPSChallengeExpired 表示房内猜拳挑战已越过无人应战截止时间,客户端应关闭应战入口并提示过期。 diff --git a/pkg/xerr/grpc_test.go b/pkg/xerr/grpc_test.go index 2c178f1e..dfee59fd 100644 --- a/pkg/xerr/grpc_test.go +++ b/pkg/xerr/grpc_test.go @@ -167,6 +167,14 @@ func TestCatalogMappings(t *testing.T) { publicCode: string(CountryChangeIdentityLocked), 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", code: DeviceAlreadyRegistered, diff --git a/scripts/apply-local-mysql-initdb.sh b/scripts/apply-local-mysql-initdb.sh index 47c80d82..3633463c 100755 --- a/scripts/apply-local-mysql-initdb.sh +++ b/scripts/apply-local-mysql-initdb.sh @@ -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/018_host_salary_policy_revisions.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 diff --git a/services/activity-service/internal/app/red_packet_event_test.go b/services/activity-service/internal/app/red_packet_event_test.go index 2feef3ce..c2b3cdcb 100644 --- a/services/activity-service/internal/app/red_packet_event_test.go +++ b/services/activity-service/internal/app/red_packet_event_test.go @@ -21,6 +21,7 @@ func TestRedPacketEventFromWalletMessageKeepsDisplayPayloadFacts(t *testing.T) { "room_id":"9001", "region_id":310, "room_locked":true, + "owner_user_id":323411852102995968, "total_amount":15000, "packet_count":5, "remaining_amount":15000, @@ -39,7 +40,7 @@ func TestRedPacketEventFromWalletMessageKeepsDisplayPayloadFacts(t *testing.T) { if err != nil || !ok { 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) } if event.SenderUserID != 10001 || event.TotalAmount != 15000 || event.TotalCount != 5 || event.RemainingAmount != 15000 || event.RemainingCount != 5 { diff --git a/services/activity-service/internal/app/wallet_events.go b/services/activity-service/internal/app/wallet_events.go index 3177f909..b17962d0 100644 --- a/services/activity-service/internal/app/wallet_events.go +++ b/services/activity-service/internal/app/wallet_events.go @@ -123,6 +123,7 @@ func redPacketEventFromWalletMessage(body []byte) (broadcastdomain.RedPacketWall RegionID: fields.RegionID, RegionCode: fields.RegionCode, RoomLocked: fields.RoomLocked, + OwnerUserID: fields.OwnerUserID, SenderUserID: fields.SenderUserID, TotalAmount: fields.TotalAmount, TotalCount: fields.TotalCount, @@ -291,6 +292,7 @@ type redPacketPayloadFields struct { RegionID int64 RegionCode string RoomLocked bool + OwnerUserID int64 SenderUserID int64 TotalAmount int64 TotalCount int32 @@ -304,7 +306,10 @@ type redPacketPayloadFields struct { func redPacketFieldsFromWalletPayload(payload string, eventType string) redPacketPayloadFields { var decoded map[string]any - if err := json.Unmarshal([]byte(payload), &decoded); err != nil { + decoder := json.NewDecoder(strings.NewReader(payload)) + // wallet payload 中的用户 ID 可能是 Snowflake int64;UseNumber 必须在首次解码时启用,先落成 float64 后无法恢复精度。 + decoder.UseNumber() + if err := decoder.Decode(&decoded); err != nil { return redPacketPayloadFields{} } packetID := stringFromDecoded(decoded, "packet_id") @@ -332,6 +337,7 @@ func redPacketFieldsFromWalletPayload(payload string, eventType string) redPacke RegionID: regionID, RegionCode: stringFromDecoded(decoded, "region_code"), RoomLocked: boolFromDecoded(decoded, "room_locked"), + OwnerUserID: int64FromDecoded(decoded, "owner_user_id"), SenderUserID: int64FromDecoded(decoded, "sender_user_id"), TotalAmount: int64FromDecoded(decoded, "total_amount"), TotalCount: totalCount, diff --git a/services/activity-service/internal/client/room_broadcast_source.go b/services/activity-service/internal/client/room_broadcast_source.go index efb0e0a3..14e4f10f 100644 --- a/services/activity-service/internal/client/room_broadcast_source.go +++ b/services/activity-service/internal/client/room_broadcast_source.go @@ -42,6 +42,7 @@ func (s *GRPCRoomBroadcastSource) GetRoomBroadcastSnapshot(ctx context.Context, RoomID: room.GetRoomId(), Title: room.GetTitle(), Locked: room.GetLocked(), + OwnerUserID: room.GetOwnerUserId(), VisibleRegionID: room.GetVisibleRegionId(), Status: room.GetStatus(), }, nil diff --git a/services/activity-service/internal/domain/broadcast/broadcast.go b/services/activity-service/internal/domain/broadcast/broadcast.go index 2715cfed..e855ed12 100644 --- a/services/activity-service/internal/domain/broadcast/broadcast.go +++ b/services/activity-service/internal/domain/broadcast/broadcast.go @@ -127,6 +127,7 @@ type RedPacketWalletEvent struct { RegionID int64 RegionCode string RoomLocked bool + OwnerUserID int64 SenderUserID int64 SenderAccount string SenderNickname string diff --git a/services/activity-service/internal/service/broadcast/game_win.go b/services/activity-service/internal/service/broadcast/game_win.go index 68f0e080..b6f9dae7 100644 --- a/services/activity-service/internal/service/broadcast/game_win.go +++ b/services/activity-service/internal/service/broadcast/game_win.go @@ -216,19 +216,24 @@ func gameWinPayload(message gamemq.GameOutboxMessage, source map[string]any, eve } if roomID != "" { payload["jump_type"] = "room_window" - payload["jump_payload"] = map[string]any{ + jumpPayload := map[string]any{ "room_id": roomID, "room_locked": room.Locked, "window": "game_window", "game_id": gameID, } - payload["action"] = map[string]any{ + action := map[string]any{ "type": "room_window", "room_id": roomID, "room_locked": room.Locked, "window": "game_window", "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) return string(encoded), err diff --git a/services/activity-service/internal/service/broadcast/lucky_gift.go b/services/activity-service/internal/service/broadcast/lucky_gift.go index a8b5ca82..e08d8361 100644 --- a/services/activity-service/internal/service/broadcast/lucky_gift.go +++ b/services/activity-service/internal/service/broadcast/lucky_gift.go @@ -142,6 +142,11 @@ func luckyGiftBigWinPayload(envelope *luckygifteventsv1.EventEnvelope, event *lu } // 字段保持拆服前 Flutter 已消费的扁平协议,并补充 granted/pretty ID 快照;不携带 gift_name, // 客户端继续用 gift_id 命中本地多语言礼物配置,避免服务端文案覆盖客户端翻译。 + action := map[string]any{ + "type": "enter_room", + "room_id": event.GetRoomId(), + "room_locked": event.GetRoomLocked(), + } payload := map[string]any{ "event_type": "lucky_gift_drawn", "event_id": eventID, @@ -179,10 +184,7 @@ func luckyGiftBigWinPayload(envelope *luckygifteventsv1.EventEnvelope, event *lu "draw_created_at_ms": createdAtMS, "reward_granted_at_ms": event.GetRewardGrantedAtMs(), "sent_at_ms": sentAtMS, - "action": map[string]any{ - "type": "enter_room", - "room_id": event.GetRoomId(), - }, + "action": action, "sender": map[string]any{ "user_id": sender.UserID, "display_user_id": sender.Account, @@ -191,6 +193,8 @@ func luckyGiftBigWinPayload(envelope *luckygifteventsv1.EventEnvelope, event *lu "avatar": sender.Avatar, }, } + setRoomOwnerSnapshot(payload, event.GetOwnerUserId()) + setRoomOwnerSnapshot(action, event.GetOwnerUserId()) encoded, err := json.Marshal(payload) return string(encoded), err } diff --git a/services/activity-service/internal/service/broadcast/red_packet_wallet_outbox.go b/services/activity-service/internal/service/broadcast/red_packet_wallet_outbox.go index 9604690e..07030667 100644 --- a/services/activity-service/internal/service/broadcast/red_packet_wallet_outbox.go +++ b/services/activity-service/internal/service/broadcast/red_packet_wallet_outbox.go @@ -117,33 +117,38 @@ func redPacketIMPayload(event broadcastdomain.RedPacketWalletEvent, eventName st if status == "" { 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{ "type": "ROOM_RED_PACKET", - "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, - }, + "data": data, } encoded, err := json.Marshal(payload) if err != nil { diff --git a/services/activity-service/internal/service/broadcast/service.go b/services/activity-service/internal/service/broadcast/service.go index d1d61dfe..2385d0af 100644 --- a/services/activity-service/internal/service/broadcast/service.go +++ b/services/activity-service/internal/service/broadcast/service.go @@ -72,6 +72,7 @@ type RoomBroadcastSnapshot struct { RoomID string Title string Locked bool + OwnerUserID int64 VisibleRegionID int64 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) { // payload 同时保留扁平字段和 sender/receiver 对象,旧客户端继续读原字段,新客户端可直接取完整用户资料。 + action := map[string]any{ + "type": "enter_room", + "room_id": envelope.GetRoomId(), + "room_locked": gift.GetRoomLocked(), + } payload := map[string]any{ "event_id": eventID, "broadcast_type": broadcastdomain.TypeSuperGift, @@ -939,11 +945,10 @@ func superGiftPayload(envelope *roomeventsv1.EventEnvelope, gift *roomeventsv1.R "source_event_id": envelope.GetEventId(), "sender": broadcastProfilePayload(sender), "receiver": broadcastProfilePayload(receiver), - "action": map[string]any{ - "type": "enter_room", - "room_id": envelope.GetRoomId(), - }, + "action": action, } + setRoomOwnerSnapshot(payload, gift.GetOwnerUserId()) + setRoomOwnerSnapshot(action, gift.GetOwnerUserId()) encoded, err := json.Marshal(payload) 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) { + action := map[string]any{ + "type": "enter_room", + "room_id": envelope.GetRoomId(), + "room_locked": rocket.GetRoomLocked(), + } payload := map[string]any{ "event_id": eventID, "broadcast_type": broadcastdomain.TypeRoomRocket, @@ -1027,16 +1037,23 @@ func roomRocketPayload(envelope *roomeventsv1.EventEnvelope, rocket *roomeventsv "current_fuel": rocket.GetCurrentFuel(), "fuel_threshold": rocket.GetFuelThreshold(), "ignited_at_ms": rocket.GetIgnitedAtMs(), - "action": map[string]any{ - "type": "enter_room", - "room_id": envelope.GetRoomId(), - "room_locked": rocket.GetRoomLocked(), - }, + "action": action, } + setRoomOwnerSnapshot(payload, rocket.GetOwnerUserId()) + setRoomOwnerSnapshot(action, rocket.GetOwnerUserId()) encoded, err := json.Marshal(payload) 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) { rewards := make([]map[string]any, 0, len(event.GetRewards())) for _, reward := range event.GetRewards() { diff --git a/services/activity-service/internal/service/broadcast/service_test.go b/services/activity-service/internal/service/broadcast/service_test.go index 14801c7c..c3c00c94 100644 --- a/services/activity-service/internal/service/broadcast/service_test.go +++ b/services/activity-service/internal/service/broadcast/service_test.go @@ -96,6 +96,7 @@ func TestHandleRoomGiftSentCreatesRegionBroadcast(t *testing.T) { GiftValue: 999, VisibleRegionId: 1001, RoomLocked: true, + OwnerUserId: 323411852102995968, CommandId: "cmd-send-gift-1", GiftName: "Rocket", 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 { 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) } + 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) receiver, _ := payload["receiver"].(map[string]any) 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", VisibleRegionId: 210, RoomLocked: true, + OwnerUserId: 323411852102995968, CountryId: 15, CoinSpent: 100, 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" { 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) } if _, exists := payload["gift_name"]; exists { t.Fatalf("region payload must let Flutter resolve localized gift name: %+v", payload) } 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) } 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.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.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") config, err := service.UpdateGameWinBroadcastConfig(ctx, true, 500, 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 { 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) } 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) } + 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.EventID = "GameOrderSettled:order-below" @@ -381,6 +391,7 @@ func TestRoomRocketPayloadCarriesRoomLockSnapshot(t *testing.T) { RocketId: "rocket-1", BroadcastScope: broadcastdomain.ScopeGlobal, RoomLocked: true, + OwnerUserId: 323411852102995968, }, "rocket-event-1", 1_700_000_000_000, SenderProfile{}) if err != nil { t.Fatalf("roomRocketPayload failed: %v", err) @@ -390,7 +401,7 @@ func TestRoomRocketPayloadCarriesRoomLockSnapshot(t *testing.T) { t.Fatalf("rocket payload is not json: %v", err) } 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) } } @@ -511,6 +522,7 @@ func TestConsumeRedPacketCreatedBuildsFullIMPayload(t *testing.T) { RoomID: "9001", RegionID: 310, RoomLocked: true, + OwnerUserID: 323411852102995968, SenderUserID: 10001, TotalAmount: 15000, TotalCount: 5, @@ -543,6 +555,9 @@ func TestConsumeRedPacketCreatedBuildsFullIMPayload(t *testing.T) { if data["room_locked"] != true { 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) { @@ -568,6 +583,7 @@ func TestDelayedRedPacketCreatedSendsRegionAndSchedulesOpenDueIM(t *testing.T) { RoomID: "9001", RegionID: 310, RoomLocked: true, + OwnerUserID: 323411852102995968, SenderUserID: 10001, TotalAmount: 15000, TotalCount: 5, @@ -596,11 +612,11 @@ func TestDelayedRedPacketCreatedSendsRegionAndSchedulesOpenDueIM(t *testing.T) { t.Fatalf("open due outbox mismatch: %+v", openDue) } 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) } 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) } diff --git a/services/gateway-service/internal/transport/http/response_test.go b/services/gateway-service/internal/transport/http/response_test.go index 7e58a711..3f747f7c 100644 --- a/services/gateway-service/internal/transport/http/response_test.go +++ b/services/gateway-service/internal/transport/http/response_test.go @@ -71,6 +71,7 @@ type fakeRoomClient struct { updateProfileErr error joinErr error joinResp *roomv1.JoinRoomResponse + micUpErr error } 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) { f.lastMicUp = req + if f.micUpErr != nil { + return nil, f.micUpErr + } 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) { router := NewHandler(&fakeRoomClient{}).Routes(auth.NewVerifier("secret")) request := httptest.NewRequest(http.MethodPost, "/api/v1/rooms/create", bytes.NewReader([]byte(`{}`))) diff --git a/services/gateway-service/internal/transport/http/walletapi/red_packet_handler.go b/services/gateway-service/internal/transport/http/walletapi/red_packet_handler.go index 925aeaf1..c12876ad 100644 --- a/services/gateway-service/internal/transport/http/walletapi/red_packet_handler.go +++ b/services/gateway-service/internal/transport/http/walletapi/red_packet_handler.go @@ -261,6 +261,7 @@ func (h *Handler) sendVoiceRoomRedPacket(writer http.ResponseWriter, request *ht TotalAmount: body.amount(), PacketCount: body.count(), RoomLocked: snapshotResp.GetRoom().GetLocked(), + OwnerUserId: snapshotResp.GetRoom().GetOwnerUserId(), }) if err != nil { writeRedPacketRPCError(writer, request, err) @@ -706,6 +707,7 @@ func (h *Handler) createRoomRedPacket(writer http.ResponseWriter, request *http. TotalAmount: totalAmount, PacketCount: packetCount, RoomLocked: snapshotResp.GetRoom().GetLocked(), + OwnerUserId: snapshotResp.GetRoom().GetOwnerUserId(), }) if err != nil { httpkit.WriteRPCError(writer, request, err) diff --git a/services/lucky-gift-service/internal/domain/luckygift/lucky_gift.go b/services/lucky-gift-service/internal/domain/luckygift/lucky_gift.go index 6b02f07e..1383d254 100644 --- a/services/lucky-gift-service/internal/domain/luckygift/lucky_gift.go +++ b/services/lucky-gift-service/internal/domain/luckygift/lucky_gift.go @@ -214,7 +214,9 @@ type DrawCommand struct { UserRegisteredAtMS int64 VisibleRegionID int64 // RoomLocked 是原始送礼时的房间展示快照,只能随 owner fact 透传,不能参与中奖概率和账务语义。 - RoomLocked bool + RoomLocked bool + // OwnerUserID 与 RoomLocked 来自同一次 Room Cell 送礼快照;它只服务跨房密码框交互,不参与抽奖资格或返奖。 + OwnerUserID int64 CountryID int64 Recharge7DCoins int64 Recharge30DCoins int64 diff --git a/services/lucky-gift-service/internal/service/luckygift/lucky_gift_event_test.go b/services/lucky-gift-service/internal/service/luckygift/lucky_gift_event_test.go index 11cd6cf5..7ec71df4 100644 --- a/services/lucky-gift-service/internal/service/luckygift/lucky_gift_event_test.go +++ b/services/lucky-gift-service/internal/service/luckygift/lucky_gift_event_test.go @@ -73,7 +73,7 @@ func TestPublishLuckyGiftDrawnFactCarriesGrantedSnapshot(t *testing.T) { if err := proto.Unmarshal(publisher.envelope.GetBody(), &fact); err != nil { 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) } } @@ -165,6 +165,7 @@ func testLuckyGiftDrawnPayload() luckyGiftDrawnPayload { SenderDisplayUserID: "123456", VisibleRegionID: 1, RoomLocked: true, + OwnerUserID: 323411852102995968, CountryID: 86, CoinSpent: 100, MultiplierPPM: luckygiftmq.LuckyGiftDisplayMinMultiplierPPM, diff --git a/services/lucky-gift-service/internal/service/luckygift/service.go b/services/lucky-gift-service/internal/service/luckygift/service.go index 629f4d41..b9d21d3d 100644 --- a/services/lucky-gift-service/internal/service/luckygift/service.go +++ b/services/lucky-gift-service/internal/service/luckygift/service.go @@ -526,6 +526,7 @@ func (s *Service) creditLuckyGiftRewardFastPath(ctx context.Context, cmd domain. SenderPrettyDisplayUserID: cmd.SenderPrettyDisplayUserID, VisibleRegionID: cmd.VisibleRegionID, RoomLocked: cmd.RoomLocked, + OwnerUserID: cmd.OwnerUserID, CountryID: cmd.CountryID, CoinSpent: cmd.CoinSpent, RuleVersion: result.RuleVersion, @@ -600,6 +601,7 @@ func (s *Service) publishLuckyGiftDrawnFact(ctx context.Context, payload luckyGi SenderPrettyDisplayUserId: payload.SenderPrettyDisplayUserID, VisibleRegionId: payload.VisibleRegionID, RoomLocked: payload.RoomLocked, + OwnerUserId: payload.OwnerUserID, CountryId: payload.CountryID, CoinSpent: payload.CoinSpent, RuleVersion: payload.RuleVersion, @@ -1086,6 +1088,7 @@ type luckyGiftDrawnPayload struct { SenderPrettyDisplayUserID string `json:"sender_pretty_display_user_id,omitempty"` VisibleRegionID int64 `json:"visible_region_id"` RoomLocked bool `json:"room_locked"` + OwnerUserID int64 `json:"owner_user_id,omitempty"` CountryID int64 `json:"country_id"` CoinSpent int64 `json:"coin_spent"` RuleVersion int64 `json:"rule_version"` diff --git a/services/lucky-gift-service/internal/storage/mysql/lucky_gift_repository.go b/services/lucky-gift-service/internal/storage/mysql/lucky_gift_repository.go index 3b81e136..0846904c 100644 --- a/services/lucky-gift-service/internal/storage/mysql/lucky_gift_repository.go +++ b/services/lucky-gift-service/internal/storage/mysql/lucky_gift_repository.go @@ -1232,6 +1232,7 @@ func (r *Repository) insertLuckyAggregateRewardSettlementOutbox(ctx context.Cont "sender_pretty_display_user_id": cmd.SenderPrettyDisplayUserID, "visible_region_id": cmd.VisibleRegionID, "room_locked": cmd.RoomLocked, + "owner_user_id": cmd.OwnerUserID, "country_id": cmd.CountryID, "room_id": cmd.RoomID, "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, "visible_region_id": input.Command.VisibleRegionID, "room_locked": input.Command.RoomLocked, + "owner_user_id": input.Command.OwnerUserID, "country_id": input.Command.CountryID, "room_id": input.Command.RoomID, "gift_id": input.Command.GiftID, diff --git a/services/lucky-gift-service/internal/transport/grpc/lucky_gift_server.go b/services/lucky-gift-service/internal/transport/grpc/lucky_gift_server.go index 7f42bcc0..e1be5a05 100644 --- a/services/lucky-gift-service/internal/transport/grpc/lucky_gift_server.go +++ b/services/lucky-gift-service/internal/transport/grpc/lucky_gift_server.go @@ -123,6 +123,7 @@ func luckyDrawCommandFromProto(meta *luckygiftv1.LuckyGiftMeta) domain.DrawComma UserRegisteredAtMS: meta.GetUserRegisteredAtMs(), VisibleRegionID: meta.GetVisibleRegionId(), RoomLocked: meta.GetRoomLocked(), + OwnerUserID: meta.GetOwnerUserId(), CountryID: meta.GetCountryId(), SenderName: meta.GetSenderName(), SenderAvatar: meta.GetSenderAvatar(), diff --git a/services/room-service/internal/room/service/gift_events.go b/services/room-service/internal/room/service/gift_events.go index 9381b7e6..62efa544 100644 --- a/services/room-service/internal/room/service/gift_events.go +++ b/services/room-service/internal/room/service/gift_events.go @@ -10,7 +10,7 @@ import ( "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 展示消息替代逐目标账务事实。 records := make([]outbox.Record, 0, len(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 只透传,避免统计和全服广播各自重新判定礼物效果。 GiftEffectTypes: targetBilling.Billing.GetGiftEffectTypes(), RoomLocked: roomLocked, + OwnerUserId: ownerUserID, IsRobotGift: cmd.RobotWalletGift, SyntheticLuckyGift: cmd.SyntheticLuckyGift, RealRoomRobotGift: cmd.RobotWalletGift && !cmd.RobotGift, diff --git a/services/room-service/internal/room/service/gift_events_test.go b/services/room-service/internal/room/service/gift_events_test.go index c65b88ec..1c000a61 100644 --- a/services/room-service/internal/room/service/gift_events_test.go +++ b/services/room-service/internal/room/service/gift_events_test.go @@ -60,7 +60,7 @@ func TestBuildRoomGiftSentRecordsPreservesDurableFactFields(t *testing.T) { 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, CommandID: "cmd-gift-fact:202", 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 { 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) } if event.GetGiftTypeCode() != "lucky" || event.GetCpRelationType() != "couple" || event.GetGiftName() != "Rose" { @@ -127,7 +127,7 @@ func TestBuildRoomGiftSentRecordsKeepsBatchDisplayFactsSplitPerTarget(t *testing 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: 203, CommandID: "cmd-gift-batch-facts:203", Billing: billing}, }, nil) @@ -142,7 +142,7 @@ func TestBuildRoomGiftSentRecordsKeepsBatchDisplayFactsSplitPerTarget(t *testing if err := proto.Unmarshal(record.Envelope.GetBody(), &event); err != nil { 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) } if event.GetDisplayMode() != "batch" { diff --git a/services/room-service/internal/room/service/gift_flow.go b/services/room-service/internal/room/service/gift_flow.go index fc16d1f0..9fee78cb 100644 --- a/services/room-service/internal/room/service/gift_flow.go +++ b/services/room-service/internal/room/service/gift_flow.go @@ -29,6 +29,8 @@ type giftFlow struct { cmd command.SendGift roomMeta RoomMeta + // ownerUserID 与 roomLocked 在同一 Room Cell 串行区间读取,RoomGiftSent 必须复用该快照。 + ownerUserID int64 // roomLocked 在进入钱包/幸运礼物副作用前从同一个 Room Cell 快照固化,保证后续所有飘屏描述同一时刻的锁房状态。 roomLocked bool 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 { + // 普通礼物事实的房主身份属于 Room Cell 核心状态;异步 activity 不能用发送者或收礼人替代。 + f.ownerUserID = current.OwnerUserID // 新命令在 Room Cell 串行区间固化锁状态;恢复优先复用首次快照。历史 saga 没有该字段时才用当前 Cell 状态兼容补齐。 if f.recovering && f.cmd.RoomLocked != nil { 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) { - 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 { return mutationResult{}, nil, err } diff --git a/services/room-service/internal/room/service/gift_lucky.go b/services/room-service/internal/room/service/gift_lucky.go index 4b3c920d..5a8b3ccb 100644 --- a/services/room-service/internal/room/service/gift_lucky.go +++ b/services/room-service/internal/room/service/gift_lucky.go @@ -116,7 +116,9 @@ func luckyGiftMetaForTarget(ctx context.Context, meta *roomv1.RequestMeta, cmd c VisibleRegionId: roomMeta.VisibleRegionID, // 锁房状态与 presence 校验取自同一个串行 Room Cell 快照;lucky owner 只持久化展示事实,不能跨库回查房间。 RoomLocked: roomLocked, - CountryId: cmd.SenderCountryID, + // 幸运礼物使用原始送礼持久 RoomMeta 的房主身份;发送者、中奖人和异步回查结果都不能替代该快照。 + OwnerUserId: roomMeta.OwnerUserID, + CountryId: cmd.SenderCountryID, // 账号创建时间来自 gateway 的 user-service 快照并随首次命令持久化;owner 只用它判断 // 支付时账号是否已满完整历史窗口。0 表示旧命令/缺失事实,补偿大奖资格必须 fail-close。 UserRegisteredAtMs: cmd.SenderRegisteredAtMS, diff --git a/services/room-service/internal/room/service/gift_robot.go b/services/room-service/internal/room/service/gift_robot.go index c3b0ec08..6a1352ee 100644 --- a/services/room-service/internal/room/service/gift_robot.go +++ b/services/room-service/internal/room/service/gift_robot.go @@ -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{ RoomID: flow.cmd.RoomID(), VisibleRegionID: snapshot.GetVisibleRegionId(), - }, snapshot.GetLocked(), targetBillings, targetGiftValues) + }, snapshot.GetLocked(), snapshot.GetOwnerUserId(), targetBillings, targetGiftValues) if err != nil { return nil, err } diff --git a/services/room-service/internal/room/service/lucky_gift_display.go b/services/room-service/internal/room/service/lucky_gift_display.go index f74a28e5..554d0184 100644 --- a/services/room-service/internal/room/service/lucky_gift_display.go +++ b/services/room-service/internal/room/service/lucky_gift_display.go @@ -58,6 +58,7 @@ func (s *Service) HandleLuckyGiftOutboxEvent(ctx context.Context, envelope *luck SenderPrettyDisplayUserId: drawn.GetSenderPrettyDisplayUserId(), VisibleRegionId: drawn.GetVisibleRegionId(), RoomLocked: drawn.GetRoomLocked(), + OwnerUserId: drawn.GetOwnerUserId(), CountryId: drawn.GetCountryId(), CoinSpent: drawn.GetCoinSpent(), RuleVersion: drawn.GetRuleVersion(), diff --git a/services/room-service/internal/room/service/lucky_gift_display_internal_test.go b/services/room-service/internal/room/service/lucky_gift_display_internal_test.go index 7a8a70f7..9d6c8731 100644 --- a/services/room-service/internal/room/service/lucky_gift_display_internal_test.go +++ b/services/room-service/internal/room/service/lucky_gift_display_internal_test.go @@ -83,7 +83,7 @@ func TestHandleLuckyGiftOutboxEventPersistsOneDerivedRoomDisplayOutbox(t *testin if err := proto.Unmarshal(record.Envelope.GetBody(), &body); err != nil { 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) } } @@ -132,6 +132,7 @@ func luckyGiftDisplayEnvelope(t *testing.T, multiplierPPM int64) *luckygiftevent SenderPrettyDisplayUserId: "VIP123456", VisibleRegionId: 1, RoomLocked: true, + OwnerUserId: 323411852102995968, CountryId: 86, CoinSpent: 100, RuleVersion: 3, diff --git a/services/room-service/internal/room/service/mic.go b/services/room-service/internal/room/service/mic.go index dd86c6d4..6049c0d5 100644 --- a/services/room-service/internal/room/service/mic.go +++ b/services/room-service/internal/room/service/mic.go @@ -39,7 +39,7 @@ func (s *Service) MicUp(ctx context.Context, req *roomv1.MicUpRequest) (*roomv1. } if current.MuteUsers[cmd.ActorUserID()] { // 禁言同时约束公屏和新上麦;校验必须落在 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 { @@ -379,7 +379,7 @@ func (s *Service) ConfirmMicPublishing(ctx context.Context, req *roomv1.ConfirmM } if current.MuteUsers[cmd.TargetUserID] { // 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) @@ -614,7 +614,7 @@ func (s *Service) SetMicMute(ctx context.Context, req *roomv1.SetMicMuteRequest) } if !cmd.Muted && current.MuteUsers[cmd.TargetUserID] { // 房间禁言优先级高于用户自己的麦克风开关;解除房间禁言前禁止目标或管理员把 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 { return mutationResult{snapshot: current.ToProto(), seatNo: seat.SeatNo}, nil, nil diff --git a/services/room-service/internal/room/service/mic_test.go b/services/room-service/internal/room/service/mic_test.go index 4c09fcdf..876e500f 100644 --- a/services/room-service/internal/room/service/mic_test.go +++ b/services/room-service/internal/room/service/mic_test.go @@ -101,8 +101,8 @@ func TestMutedUserCannotSpeakOrMicUpUntilUnmuted(t *testing.T) { if _, err := svc.MicUp(ctx, &roomv1.MicUpRequest{ Meta: rocketMeta(roomID, mutedUserID, "muted-mic-up"), SeatNo: 2, - }); !xerr.IsCode(err, xerr.PermissionDenied) { - t.Fatalf("muted user mic up must return permission denied: %v", err) + }); !xerr.IsCode(err, xerr.RoomUserMuted) { + t.Fatalf("muted user mic up must return room user muted: %v", err) } if _, err := svc.MuteUser(ctx, &roomv1.MuteUserRequest{ @@ -175,7 +175,7 @@ func TestMutedUserCannotSpeakOrMicUpUntilUnmuted(t *testing.T) { RoomVersion: upResp.GetResult().GetRoomVersion(), EventTimeMs: now.Now().UnixMilli(), 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) } rtcEventResp, err := svc.ApplyRTCEvent(ctx, &roomv1.ApplyRTCEventRequest{ diff --git a/services/room-service/internal/room/service/room_rocket.go b/services/room-service/internal/room/service/room_rocket.go index 6ca2c0d4..6cb3edaf 100644 --- a/services/room-service/internal/room/service/room_rocket.go +++ b/services/room-service/internal/room/service/room_rocket.go @@ -438,6 +438,8 @@ func (s *Service) applyRoomRocketGift(now time.Time, current *state.RoomState, c RoomShortId: roomMeta.RoomShortID, RocketCoverUrl: pending.CoverURL, ResetAtMs: pending.ResetAtMS, + // 构造函数同时服务普通送礼和 RPS 礼物;在这里读取 Cell 房主才能保证两条点火链路不漏字段。 + OwnerUserId: current.OwnerUserID, } if rocket.CurrentLevel < roomRocketLevelCount { nextLevel := rocket.CurrentLevel + 1 diff --git a/services/room-service/internal/room/service/room_rocket_test.go b/services/room-service/internal/room/service/room_rocket_test.go index 7ccdfff6..f444bd24 100644 --- a/services/room-service/internal/room/service/room_rocket_test.go +++ b/services/room-service/internal/room/service/room_rocket_test.go @@ -1340,7 +1340,7 @@ func TestRoomRocketGiftOverflowAndImmediateNextLevel(t *testing.T) { waitForRoomDirectIMCounts(t, directIM, map[string]int{"RoomRocketIgnited": 1}) ignitedRecords := directIM.recordsByType("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) } if got := outboxEventCounts(t, ctx, repository)["RoomRocketIgnited"]; got != 1 { diff --git a/services/statistics-service/deploy/mysql/initdb/001_statistics_service.sql b/services/statistics-service/deploy/mysql/initdb/001_statistics_service.sql index b9093454..d6a6ea52 100644 --- a/services/statistics-service/deploy/mysql/initdb/001_statistics_service.sql +++ b/services/statistics-service/deploy/mysql/initdb/001_statistics_service.sql @@ -156,6 +156,7 @@ CREATE TABLE IF NOT EXISTS stat_social_user_day ( region_id BIGINT NOT NULL DEFAULT 0 COMMENT '事件发生时区域维度', user_role VARCHAR(16) NOT NULL DEFAULT 'user' COMMENT '当日用户身份快照,user 或 host', 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 '注册成功去重标记', active_user TINYINT(1) NOT NULL DEFAULT 0 COMMENT '当日活跃去重标记', room_join_success TINYINT(1) NOT NULL DEFAULT 0 COMMENT '进房成功去重标记', diff --git a/services/statistics-service/deploy/mysql/migrations/003_social_profile_setup_view.sql b/services/statistics-service/deploy/mysql/migrations/003_social_profile_setup_view.sql new file mode 100644 index 00000000..36a98d8e --- /dev/null +++ b/services/statistics-service/deploy/mysql/migrations/003_social_profile_setup_view.sql @@ -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'; diff --git a/services/statistics-service/internal/storage/mysql/query_test.go b/services/statistics-service/internal/storage/mysql/query_test.go index 90e3191a..d7780eb4 100644 --- a/services/statistics-service/internal/storage/mysql/query_test.go +++ b/services/statistics-service/internal/storage/mysql/query_test.go @@ -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) { ctx := context.Background() _, file, _, _ := runtime.Caller(0) @@ -740,6 +772,9 @@ func TestQuerySocialRequirementsAggregatesP0ToP5(t *testing.T) { 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: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: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()}, @@ -827,6 +862,7 @@ func TestQuerySocialRequirementsAggregatesP0ToP5(t *testing.T) { } newUsers := socialTestSection(t, result, SocialSectionNewUsers) 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, "new_host_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 { 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) // 文档口径:总充值 2100 = Google 500 + 三方(MiFaPay) 700 + 币商(finance verified) 900。 diff --git a/services/statistics-service/internal/storage/mysql/repository.go b/services/statistics-service/internal/storage/mysql/repository.go index 9a87008e..1076451c 100644 --- a/services/statistics-service/internal/storage/mysql/repository.go +++ b/services/statistics-service/internal/storage/mysql/repository.go @@ -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, 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', - 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, 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, @@ -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_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`, + // 单个带默认值的标记列使用 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_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`, diff --git a/services/statistics-service/internal/storage/mysql/social_requirements_query.go b/services/statistics-service/internal/storage/mysql/social_requirements_query.go index 14e578a9..69a9d286 100644 --- a/services/statistics-service/internal/storage/mysql/social_requirements_query.go +++ b/services/statistics-service/internal/storage/mysql/social_requirements_query.go @@ -107,6 +107,7 @@ type socialRequirementUserDay struct { RegionID int64 UserRole string AppFirstOpen int64 + ProfileSetupView int64 Registered int64 Active int64 RoomJoinOK int64 @@ -155,6 +156,7 @@ type socialRequirementUserDay struct { type socialRequirementAggregate struct { AppFirstOpen int64 + ProfileSetupView int64 Registered int64 NewHostUsers int64 NewNormalUsers int64 @@ -385,7 +387,7 @@ func (r *Repository) querySocialUserDays(ctx context.Context, app string, statTZ // 过滤会丢掉同一用户登录前的活跃,并让区域 DAU 再次出现 d/u 口径偏差。 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, - 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, 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, @@ -407,7 +409,7 @@ func (r *Repository) querySocialUserDays(ctx context.Context, app string, statTZ for rows.Next() { var item socialRequirementUserDay 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.Recharge, &item.FirstRecharge, &item.RepeatRecharge, &item.GameBet, &item.SelfGameBet, &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 // device count after all rows merge; MAX is only the empty-device fallback. current.AppFirstOpen = maxSocialFlag(current.AppFirstOpen, next.AppFirstOpen) + current.ProfileSetupView = maxSocialFlag(current.ProfileSetupView, next.ProfileSetupView) current.Registered = maxSocialFlag(current.Registered, next.Registered) current.Active = maxSocialFlag(current.Active, next.Active) current.RoomJoinOK = maxSocialFlag(current.RoomJoinOK, next.RoomJoinOK) @@ -786,6 +789,9 @@ func aggregateSocialRows(rows []socialRequirementUserDay, section string, role s // 下载/首次打开是设备口径;身份归并只消除用户行为的 d/u 重复, // 不能把同一账号在两台设备上的两次安装压成 1。 agg.AppFirstOpen += normalizeID(row.AppFirstOpen) + // 资料页曝光按 canonical 主体日去重:登录用户按 user_id,注册前匿名流程按 device_id; + // 同一客户端会话完成登录后,查询阶段会把 d:/u: 两行合并,避免一次曝光重复计数。 + agg.ProfileSetupView += flagValue(row.ProfileSetupView) agg.Registered += flagValue(row.Registered) if flagValue(row.Registered) > 0 && normalizeSocialUserRole(row.UserRole) == "host" { agg.NewHostUsers++ @@ -1118,6 +1124,7 @@ func socialSectionMetrics(section string, agg socialRequirementAggregate, retent default: out := map[string]any{ "app_download_users": agg.AppFirstOpen, + "profile_setup_view_users": agg.ProfileSetupView, "registered_users": agg.Registered, "new_host_users": agg.NewHostUsers, "new_normal_users": agg.NewNormalUsers, @@ -1312,6 +1319,7 @@ func socialRequirementMetricDefinitions(section string) []MetricDefinition { default: return []MetricDefinition{ {Metric: "app_download_users", Definition: "客户端 app_first_open 去重人数;不代表应用商店真实下载量。"}, + {Metric: "profile_setup_view_users", Definition: "进入填写资料页的新用户人数:客户端 profile_start 事件按 canonical 用户或设备主体、统计自然日去重。"}, // registered_users 保留历史字段兼容,业务展示统一为“新增用户”;资格和幂等边界由 UserRegistered 生产、消费链路保证。 {Metric: "registered_users", Definition: "注册成功的新用户人数:仅统计资料完成后由 user-service 发布的 UserRegistered 服务端事实,按用户去重;未完成资料、quick_account、game_robot 不计,MQ 重投不重复。"}, {Metric: "new_user_d1_retention_rate", Definition: "新普通用户次日活跃留存。"}, @@ -1422,6 +1430,7 @@ func socialRequirementColumns(section string) []SocialRequirementColumn { default: return []SocialRequirementColumn{ {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: "new_host_users", Label: "新主播用户", Type: "count"}, {Key: "new_normal_users", Label: "新普通用户", Type: "count"}, diff --git a/services/statistics-service/internal/storage/mysql/social_user_day.go b/services/statistics-service/internal/storage/mysql/social_user_day.go index 1f5796ba..bf99d9c5 100644 --- a/services/statistics-service/internal/storage/mysql/social_user_day.go +++ b/services/statistics-service/internal/storage/mysql/social_user_day.go @@ -20,6 +20,7 @@ type socialUserDayDelta struct { UserRole string UpdatedAt int64 FirstOpen int64 + ProfileSetupView int64 Registered int64 Active int64 RoomJoinOK int64 @@ -99,6 +100,7 @@ func applySocialUserDayDelta(ctx context.Context, tx *sql.Tx, delta socialUserDa _, err := tx.ExecContext(ctx, ` UPDATE stat_social_user_day SET app_first_open = GREATEST(app_first_open, ?), + profile_setup_view = GREATEST(profile_setup_view, ?), registered_success = GREATEST(registered_success, ?), active_user = GREATEST(active_user, ?), 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 + ?, updated_at_ms = ? 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.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), @@ -216,6 +218,10 @@ func socialDeltaForAppTrackingEvent(event AppTrackingEvent, scope statDayScope, case "app_first_open": delta.FirstOpen = 1 delta.Active = 1 + case "profile_start": + // Flutter 仅在未完成资料的新用户真正进入 profile_setup 页面后上报; + // 用户退出重进可能重复上报,因此读模型只保留主体日级曝光标记。 + delta.ProfileSetupView = 1 case "home_view": delta.Active = 1 case "app_session": diff --git a/services/wallet-service/internal/domain/ledger/red_packet.go b/services/wallet-service/internal/domain/ledger/red_packet.go index e4ff1594..22a91da0 100644 --- a/services/wallet-service/internal/domain/ledger/red_packet.go +++ b/services/wallet-service/internal/domain/ledger/red_packet.go @@ -71,6 +71,8 @@ type RedPacketCreateCommand struct { PacketCount int32 // RoomLocked 是 gateway 从 room-service snapshot 读取的展示快照,只随红包资金事实透传。 RoomLocked bool + // OwnerUserID 与 RoomLocked 来自同一份房间快照;wallet 不解释房主权限,只随已提交红包事实透传。 + OwnerUserID int64 } type RedPacketCreateReceipt struct { diff --git a/services/wallet-service/internal/storage/mysql/red_packet_constants.go b/services/wallet-service/internal/storage/mysql/red_packet_constants.go index 9da9d1ea..c4d0cdb4 100644 --- a/services/wallet-service/internal/storage/mysql/red_packet_constants.go +++ b/services/wallet-service/internal/storage/mysql/red_packet_constants.go @@ -27,6 +27,7 @@ type redPacketMetadata struct { RoomID string `json:"room_id"` RegionID int64 `json:"region_id"` RoomLocked bool `json:"room_locked"` + OwnerUserID int64 `json:"owner_user_id,omitempty"` PacketType string `json:"packet_type"` TotalAmount int64 `json:"total_amount"` PacketCount int32 `json:"packet_count"` diff --git a/services/wallet-service/internal/storage/mysql/red_packet_create_repository.go b/services/wallet-service/internal/storage/mysql/red_packet_create_repository.go index c558ebdd..b379b719 100644 --- a/services/wallet-service/internal/storage/mysql/red_packet_create_repository.go +++ b/services/wallet-service/internal/storage/mysql/red_packet_create_repository.go @@ -90,6 +90,7 @@ func (r *Repository) CreateRedPacket(ctx context.Context, command ledger.RedPack RoomID: command.RoomID, RegionID: command.RegionID, RoomLocked: command.RoomLocked, + OwnerUserID: command.OwnerUserID, PacketType: command.PacketType, TotalAmount: command.TotalAmount, PacketCount: command.PacketCount, @@ -228,6 +229,7 @@ func redPacketCreatedEvent(transactionID string, commandID string, metadata redP "room_id": metadata.RoomID, "region_id": metadata.RegionID, "room_locked": metadata.RoomLocked, + "owner_user_id": metadata.OwnerUserID, "total_amount": metadata.TotalAmount, "packet_count": metadata.PacketCount, "remaining_amount": metadata.RemainingAmount, @@ -242,7 +244,7 @@ func redPacketCreatedEvent(transactionID string, commandID string, metadata redP } 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", appcode.Normalize(command.AppCode), command.CommandID, diff --git a/services/wallet-service/internal/storage/mysql/red_packet_repository_test.go b/services/wallet-service/internal/storage/mysql/red_packet_repository_test.go index 8a78cacd..0c73cbb3 100644 --- a/services/wallet-service/internal/storage/mysql/red_packet_repository_test.go +++ b/services/wallet-service/internal/storage/mysql/red_packet_repository_test.go @@ -14,6 +14,7 @@ func TestRedPacketCreatedEventCarriesDisplayFacts(t *testing.T) { RoomID: "9001", RegionID: 310, RoomLocked: true, + OwnerUserID: 323411852102995968, TotalAmount: 15000, PacketCount: 5, RemainingAmount: 15000, @@ -37,4 +38,7 @@ func TestRedPacketCreatedEventCarriesDisplayFacts(t *testing.T) { if payload["room_locked"] != true { 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) + } } diff --git a/services/wallet-service/internal/transport/grpc/red_packet.go b/services/wallet-service/internal/transport/grpc/red_packet.go index 419ccd0c..3031c9f0 100644 --- a/services/wallet-service/internal/transport/grpc/red_packet.go +++ b/services/wallet-service/internal/transport/grpc/red_packet.go @@ -56,6 +56,7 @@ func (s *Server) CreateRedPacket(ctx context.Context, req *walletv1.CreateRedPac TotalAmount: req.GetTotalAmount(), PacketCount: req.GetPacketCount(), RoomLocked: req.GetRoomLocked(), + OwnerUserID: req.GetOwnerUserId(), }) if err != nil { return nil, xerr.ToGRPCError(err)