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

@ -146,6 +146,8 @@ type LuckyGiftDrawn struct {
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"`
// 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

View File

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

View File

@ -1725,6 +1725,8 @@ 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"`
// 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
}
@ -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 {
@ -2554,6 +2563,8 @@ type RoomLuckyGiftDrawn struct {
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"`
// 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 {
@ -3309,6 +3327,8 @@ type RoomRocketIgnited struct {
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"`
// 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" +

View File

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

View File

@ -135,6 +135,8 @@ type LuckyGiftMeta struct {
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"`
// 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" +

View File

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

View File

@ -24068,6 +24068,8 @@ type CreateRedPacketRequest struct {
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"`
// 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" +

View File

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

View File

@ -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 也落在查询周期内的用户数。 |

View File

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

View File

@ -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 表示房内猜拳挑战已越过无人应战截止时间,客户端应关闭应战入口并提示过期。

View File

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

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

View File

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

View File

@ -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 int64UseNumber 必须在首次解码时启用,先落成 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,

View File

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

View File

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

View File

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

View File

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

View File

@ -117,9 +117,7 @@ func redPacketIMPayload(event broadcastdomain.RedPacketWalletEvent, eventName st
if status == "" {
status = "ACTIVE"
}
payload := map[string]any{
"type": "ROOM_RED_PACKET",
"data": map[string]any{
data := map[string]any{
"event": eventName,
"scope": scope,
"packetNo": event.PacketID,
@ -143,7 +141,14 @@ func redPacketIMPayload(event broadcastdomain.RedPacketWalletEvent, eventName st
"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": data,
}
encoded, err := json.Marshal(payload)
if err != nil {

View File

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

View File

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

View File

@ -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(`{}`)))

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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{
RoomID: flow.cmd.RoomID(),
VisibleRegionID: snapshot.GetVisibleRegionId(),
}, snapshot.GetLocked(), targetBillings, targetGiftValues)
}, snapshot.GetLocked(), snapshot.GetOwnerUserId(), targetBillings, targetGiftValues)
if err != nil {
return nil, err
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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 '进房成功去重标记',

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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