游戏配置

This commit is contained in:
zhx 2026-07-20 20:18:59 +08:00
parent 4c980ddeca
commit 35ca1ce071
56 changed files with 3261 additions and 1753 deletions

File diff suppressed because it is too large Load Diff

View File

@ -408,6 +408,35 @@ message ProcessBroadcastOutboxBatchResponse {
bool has_more = 4;
}
// GameWinBroadcastConfig activity-service App
// game-service
message GameWinBroadcastConfig {
bool enabled = 1;
int64 min_win_coins = 2;
int64 updated_by_admin_id = 3;
int64 created_at_ms = 4;
int64 updated_at_ms = 5;
}
message GetGameWinBroadcastConfigRequest {
RequestMeta meta = 1;
}
message GetGameWinBroadcastConfigResponse {
GameWinBroadcastConfig config = 1;
}
message UpdateGameWinBroadcastConfigRequest {
RequestMeta meta = 1;
bool enabled = 2;
int64 min_win_coins = 3;
int64 operator_admin_id = 4;
}
message UpdateGameWinBroadcastConfigResponse {
GameWinBroadcastConfig config = 1;
}
message ConsumeRoomEventRequest {
RequestMeta meta = 1;
hyapp.events.room.v1.EventEnvelope envelope = 2;
@ -3018,6 +3047,12 @@ service BroadcastService {
rpc ProcessBroadcastOutboxBatch(CronBatchRequest) returns (ProcessBroadcastOutboxBatchResponse);
}
// AdminBroadcastService activity-service
service AdminBroadcastService {
rpc GetGameWinBroadcastConfig(GetGameWinBroadcastConfigRequest) returns (GetGameWinBroadcastConfigResponse);
rpc UpdateGameWinBroadcastConfig(UpdateGameWinBroadcastConfigRequest) returns (UpdateGameWinBroadcastConfigResponse);
}
// RoomEventConsumerService consumes room-service outbox facts without owning Room Cell state.
service RoomEventConsumerService {
rpc ConsumeRoomEvent(ConsumeRoomEventRequest) returns (ConsumeRoomEventResponse);

View File

@ -3063,6 +3063,150 @@ var BroadcastService_ServiceDesc = grpc.ServiceDesc{
Metadata: "proto/activity/v1/activity.proto",
}
const (
AdminBroadcastService_GetGameWinBroadcastConfig_FullMethodName = "/hyapp.activity.v1.AdminBroadcastService/GetGameWinBroadcastConfig"
AdminBroadcastService_UpdateGameWinBroadcastConfig_FullMethodName = "/hyapp.activity.v1.AdminBroadcastService/UpdateGameWinBroadcastConfig"
)
// AdminBroadcastServiceClient is the client API for AdminBroadcastService service.
//
// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream.
//
// AdminBroadcastService 是后台读写飘屏策略的唯一入口;配置仍由 activity-service 持久化和执行。
type AdminBroadcastServiceClient interface {
GetGameWinBroadcastConfig(ctx context.Context, in *GetGameWinBroadcastConfigRequest, opts ...grpc.CallOption) (*GetGameWinBroadcastConfigResponse, error)
UpdateGameWinBroadcastConfig(ctx context.Context, in *UpdateGameWinBroadcastConfigRequest, opts ...grpc.CallOption) (*UpdateGameWinBroadcastConfigResponse, error)
}
type adminBroadcastServiceClient struct {
cc grpc.ClientConnInterface
}
func NewAdminBroadcastServiceClient(cc grpc.ClientConnInterface) AdminBroadcastServiceClient {
return &adminBroadcastServiceClient{cc}
}
func (c *adminBroadcastServiceClient) GetGameWinBroadcastConfig(ctx context.Context, in *GetGameWinBroadcastConfigRequest, opts ...grpc.CallOption) (*GetGameWinBroadcastConfigResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(GetGameWinBroadcastConfigResponse)
err := c.cc.Invoke(ctx, AdminBroadcastService_GetGameWinBroadcastConfig_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *adminBroadcastServiceClient) UpdateGameWinBroadcastConfig(ctx context.Context, in *UpdateGameWinBroadcastConfigRequest, opts ...grpc.CallOption) (*UpdateGameWinBroadcastConfigResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(UpdateGameWinBroadcastConfigResponse)
err := c.cc.Invoke(ctx, AdminBroadcastService_UpdateGameWinBroadcastConfig_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
// AdminBroadcastServiceServer is the server API for AdminBroadcastService service.
// All implementations must embed UnimplementedAdminBroadcastServiceServer
// for forward compatibility.
//
// AdminBroadcastService 是后台读写飘屏策略的唯一入口;配置仍由 activity-service 持久化和执行。
type AdminBroadcastServiceServer interface {
GetGameWinBroadcastConfig(context.Context, *GetGameWinBroadcastConfigRequest) (*GetGameWinBroadcastConfigResponse, error)
UpdateGameWinBroadcastConfig(context.Context, *UpdateGameWinBroadcastConfigRequest) (*UpdateGameWinBroadcastConfigResponse, error)
mustEmbedUnimplementedAdminBroadcastServiceServer()
}
// UnimplementedAdminBroadcastServiceServer must be embedded to have
// forward compatible implementations.
//
// NOTE: this should be embedded by value instead of pointer to avoid a nil
// pointer dereference when methods are called.
type UnimplementedAdminBroadcastServiceServer struct{}
func (UnimplementedAdminBroadcastServiceServer) GetGameWinBroadcastConfig(context.Context, *GetGameWinBroadcastConfigRequest) (*GetGameWinBroadcastConfigResponse, error) {
return nil, status.Error(codes.Unimplemented, "method GetGameWinBroadcastConfig not implemented")
}
func (UnimplementedAdminBroadcastServiceServer) UpdateGameWinBroadcastConfig(context.Context, *UpdateGameWinBroadcastConfigRequest) (*UpdateGameWinBroadcastConfigResponse, error) {
return nil, status.Error(codes.Unimplemented, "method UpdateGameWinBroadcastConfig not implemented")
}
func (UnimplementedAdminBroadcastServiceServer) mustEmbedUnimplementedAdminBroadcastServiceServer() {}
func (UnimplementedAdminBroadcastServiceServer) testEmbeddedByValue() {}
// UnsafeAdminBroadcastServiceServer may be embedded to opt out of forward compatibility for this service.
// Use of this interface is not recommended, as added methods to AdminBroadcastServiceServer will
// result in compilation errors.
type UnsafeAdminBroadcastServiceServer interface {
mustEmbedUnimplementedAdminBroadcastServiceServer()
}
func RegisterAdminBroadcastServiceServer(s grpc.ServiceRegistrar, srv AdminBroadcastServiceServer) {
// If the following call panics, it indicates UnimplementedAdminBroadcastServiceServer was
// embedded by pointer and is nil. This will cause panics if an
// unimplemented method is ever invoked, so we test this at initialization
// time to prevent it from happening at runtime later due to I/O.
if t, ok := srv.(interface{ testEmbeddedByValue() }); ok {
t.testEmbeddedByValue()
}
s.RegisterService(&AdminBroadcastService_ServiceDesc, srv)
}
func _AdminBroadcastService_GetGameWinBroadcastConfig_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(GetGameWinBroadcastConfigRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(AdminBroadcastServiceServer).GetGameWinBroadcastConfig(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: AdminBroadcastService_GetGameWinBroadcastConfig_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(AdminBroadcastServiceServer).GetGameWinBroadcastConfig(ctx, req.(*GetGameWinBroadcastConfigRequest))
}
return interceptor(ctx, in, info, handler)
}
func _AdminBroadcastService_UpdateGameWinBroadcastConfig_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(UpdateGameWinBroadcastConfigRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(AdminBroadcastServiceServer).UpdateGameWinBroadcastConfig(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: AdminBroadcastService_UpdateGameWinBroadcastConfig_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(AdminBroadcastServiceServer).UpdateGameWinBroadcastConfig(ctx, req.(*UpdateGameWinBroadcastConfigRequest))
}
return interceptor(ctx, in, info, handler)
}
// AdminBroadcastService_ServiceDesc is the grpc.ServiceDesc for AdminBroadcastService service.
// It's only intended for direct use with grpc.RegisterService,
// and not to be introspected or modified (even as a copy)
var AdminBroadcastService_ServiceDesc = grpc.ServiceDesc{
ServiceName: "hyapp.activity.v1.AdminBroadcastService",
HandlerType: (*AdminBroadcastServiceServer)(nil),
Methods: []grpc.MethodDesc{
{
MethodName: "GetGameWinBroadcastConfig",
Handler: _AdminBroadcastService_GetGameWinBroadcastConfig_Handler,
},
{
MethodName: "UpdateGameWinBroadcastConfig",
Handler: _AdminBroadcastService_UpdateGameWinBroadcastConfig_Handler,
},
},
Streams: []grpc.StreamDesc{},
Metadata: "proto/activity/v1/activity.proto",
}
const (
RoomEventConsumerService_ConsumeRoomEvent_FullMethodName = "/hyapp.activity.v1.RoomEventConsumerService/ConsumeRoomEvent"
)

View File

@ -144,8 +144,10 @@ type LuckyGiftDrawn struct {
// 两个时间均为 UTC Unix epoch milliseconds抽奖发生时间与钱包返奖确认时间不能混用。
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"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
// room_locked 是 room-service 在原始送礼命令内固化的 Room Cell 锁状态;下游不能异步回查当前房间再改写历史事实。
RoomLocked bool `protobuf:"varint,28,opt,name=room_locked,json=roomLocked,proto3" json:"room_locked,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *LuckyGiftDrawn) Reset() {
@ -367,6 +369,13 @@ func (x *LuckyGiftDrawn) GetRewardGrantedAtMs() int64 {
return 0
}
func (x *LuckyGiftDrawn) GetRoomLocked() bool {
if x != nil {
return x.RoomLocked
}
return false
}
var File_proto_events_luckygift_v1_events_proto protoreflect.FileDescriptor
const file_proto_events_luckygift_v1_events_proto_rawDesc = "" +
@ -379,7 +388,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\"\xab\b\n" +
"\x04body\x18\x06 \x01(\fR\x04body\"\xcc\b\n" +
"\x0eLuckyGiftDrawn\x12\x17\n" +
"\adraw_id\x18\x01 \x01(\tR\x06drawId\x12\x1d\n" +
"\n" +
@ -413,7 +422,9 @@ const file_proto_events_luckygift_v1_events_proto_rawDesc = "" +
"\rreward_status\x18\x18 \x01(\tR\frewardStatus\x122\n" +
"\x15wallet_transaction_id\x18\x19 \x01(\tR\x13walletTransactionId\x12+\n" +
"\x12draw_created_at_ms\x18\x1a \x01(\x03R\x0fdrawCreatedAtMs\x12/\n" +
"\x14reward_granted_at_ms\x18\x1b \x01(\x03R\x11rewardGrantedAtMsB=Z;hyapp.local/api/proto/events/luckygift/v1;luckygifteventsv1b\x06proto3"
"\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"
var (
file_proto_events_luckygift_v1_events_proto_rawDescOnce sync.Once

View File

@ -51,4 +51,6 @@ message LuckyGiftDrawn {
// UTC Unix epoch milliseconds
int64 draw_created_at_ms = 26;
int64 reward_granted_at_ms = 27;
// room_locked room-service Room Cell
bool room_locked = 28;
}

View File

@ -1718,7 +1718,9 @@ type RoomGiftSent struct {
ReceiverDisplayUserId string `protobuf:"bytes,29,opt,name=receiver_display_user_id,json=receiverDisplayUserId,proto3" json:"receiver_display_user_id,omitempty"`
ReceiverPrettyDisplayUserId string `protobuf:"bytes,30,opt,name=receiver_pretty_display_user_id,json=receiverPrettyDisplayUserId,proto3" json:"receiver_pretty_display_user_id,omitempty"`
// display_mode=batch 表示这条逐目标事实只给统计、activity、CP 和礼物墙消费;房间展示由同命令的 RoomGiftBatchSent 承担。
DisplayMode string `protobuf:"bytes,31,opt,name=display_mode,json=displayMode,proto3" json:"display_mode,omitempty"`
DisplayMode string `protobuf:"bytes,31,opt,name=display_mode,json=displayMode,proto3" json:"display_mode,omitempty"`
// room_locked 固化送礼命令在 Room Cell 内执行时的锁房状态,跨房飘屏点击不能依赖首页缓存猜测。
RoomLocked bool `protobuf:"varint,32,opt,name=room_locked,json=roomLocked,proto3" json:"room_locked,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
@ -1970,6 +1972,13 @@ func (x *RoomGiftSent) GetDisplayMode() string {
return ""
}
func (x *RoomGiftSent) GetRoomLocked() bool {
if x != nil {
return x.RoomLocked
}
return false
}
// RoomGiftBatchLuckyResult 是批量送礼展示事件内的逐目标幸运礼物结果快照。
// 它只进入房间 IM 展示,不作为 activity 中奖事实,也不携带送礼人余额。
type RoomGiftBatchLuckyResult struct {
@ -2507,8 +2516,10 @@ type RoomLuckyGiftDrawn struct {
// 时间沿用 lucky owner 的 UTC Unix epoch milliseconds不以 room-service 消费时间覆盖。
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"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
// room_locked 来自 lucky owner 事实中的送礼时房间快照,仅用于客户端点击飘屏前决定是否先收集密码。
RoomLocked bool `protobuf:"varint,27,opt,name=room_locked,json=roomLocked,proto3" json:"room_locked,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *RoomLuckyGiftDrawn) Reset() {
@ -2723,6 +2734,13 @@ func (x *RoomLuckyGiftDrawn) GetRewardGrantedAtMs() int64 {
return 0
}
func (x *RoomLuckyGiftDrawn) GetRoomLocked() bool {
if x != nil {
return x.RoomLocked
}
return false
}
// RoomRobotLuckyGiftDrawn 是机器人房间专用幸运礼物展示事件。
// 它复用客户端 lucky_gift_drawn 表现协议,但不代表真实奖池抽奖和钱包返奖事实。
type RoomRobotLuckyGiftDrawn struct {
@ -3720,7 +3738,8 @@ 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\"\xf1\t\n" +
"\x0etarget_user_id\x18\x02 \x01(\x03R\ftargetUserId\"\x92\n" +
"\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" +
@ -3759,7 +3778,9 @@ const file_proto_events_room_v1_events_proto_rawDesc = "" +
"\x0freceiver_avatar\x18\x1c \x01(\tR\x0ereceiverAvatar\x127\n" +
"\x18receiver_display_user_id\x18\x1d \x01(\tR\x15receiverDisplayUserId\x12D\n" +
"\x1freceiver_pretty_display_user_id\x18\x1e \x01(\tR\x1breceiverPrettyDisplayUserId\x12!\n" +
"\fdisplay_mode\x18\x1f \x01(\tR\vdisplayMode\"\xdc\x04\n" +
"\fdisplay_mode\x18\x1f \x01(\tR\vdisplayMode\x12\x1f\n" +
"\vroom_locked\x18 \x01(\bR\n" +
"roomLocked\"\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" +
@ -3822,7 +3843,7 @@ const file_proto_events_room_v1_events_proto_rawDesc = "" +
"\atargets\x18\x14 \x03(\v2).hyapp.events.room.v1.RoomGiftBatchTargetR\atargets\x12\x1d\n" +
"\n" +
"command_id\x18\x15 \x01(\tR\tcommandId\x12*\n" +
"\x11visible_region_id\x18\x16 \x01(\x03R\x0fvisibleRegionId\"\x96\b\n" +
"\x11visible_region_id\x18\x16 \x01(\x03R\x0fvisibleRegionId\"\xb7\b\n" +
"\x12RoomLuckyGiftDrawn\x12\x17\n" +
"\adraw_id\x18\x01 \x01(\tR\x06drawId\x12\x1d\n" +
"\n" +
@ -3855,7 +3876,9 @@ const file_proto_events_room_v1_events_proto_rawDesc = "" +
"\rreward_status\x18\x17 \x01(\tR\frewardStatus\x122\n" +
"\x15wallet_transaction_id\x18\x18 \x01(\tR\x13walletTransactionId\x12+\n" +
"\x12draw_created_at_ms\x18\x19 \x01(\x03R\x0fdrawCreatedAtMs\x12/\n" +
"\x14reward_granted_at_ms\x18\x1a \x01(\x03R\x11rewardGrantedAtMs\"\x80\x04\n" +
"\x14reward_granted_at_ms\x18\x1a \x01(\x03R\x11rewardGrantedAtMs\x12\x1f\n" +
"\vroom_locked\x18\x1b \x01(\bR\n" +
"roomLocked\"\x80\x04\n" +
"\x17RoomRobotLuckyGiftDrawn\x12\x17\n" +
"\adraw_id\x18\x01 \x01(\tR\x06drawId\x12\x1d\n" +
"\n" +

View File

@ -248,6 +248,8 @@ message RoomGiftSent {
string receiver_pretty_display_user_id = 30;
// display_mode=batch activityCP RoomGiftBatchSent
string display_mode = 31;
// room_locked Room Cell
bool room_locked = 32;
}
// RoomGiftBatchLuckyResult
@ -344,6 +346,8 @@ message RoomLuckyGiftDrawn {
// 沿 lucky owner UTC Unix epoch milliseconds room-service
int64 draw_created_at_ms = 25;
int64 reward_granted_at_ms = 26;
// room_locked lucky owner
bool room_locked = 27;
}
// RoomRobotLuckyGiftDrawn

View File

@ -132,8 +132,10 @@ type LuckyGiftMeta struct {
// user_registered_at_ms 是 user-service 在送礼入口返回的账号创建事实快照。
// 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"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
// room_locked 由 Room Cell 在送礼命令串行执行期间读取,只进入中奖展示事实,不参与抽奖或账务计算。
RoomLocked bool `protobuf:"varint,24,opt,name=room_locked,json=roomLocked,proto3" json:"room_locked,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *LuckyGiftMeta) Reset() {
@ -327,6 +329,13 @@ func (x *LuckyGiftMeta) GetUserRegisteredAtMs() int64 {
return 0
}
func (x *LuckyGiftMeta) GetRoomLocked() bool {
if x != nil {
return x.RoomLocked
}
return false
}
type LuckyGiftRuleTier struct {
state protoimpl.MessageState `protogen:"open.v1"`
Stage string `protobuf:"bytes,1,opt,name=stage,proto3" json:"stage,omitempty"`
@ -5618,7 +5627,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\"\xf5\x06\n" +
"\bapp_code\x18\x05 \x01(\tR\aappCode\"\x96\a\n" +
"\rLuckyGiftMeta\x123\n" +
"\x04meta\x18\x01 \x01(\v2\x1f.hyapp.luckygift.v1.RequestMetaR\x04meta\x12\x1d\n" +
"\n" +
@ -5649,7 +5658,9 @@ const file_proto_luckygift_v1_luckygift_proto_rawDesc = "" +
"\x12recharge_30d_coins\x18\x14 \x01(\x03R\x10recharge30dCoins\x12/\n" +
"\x14last_recharged_at_ms\x18\x15 \x01(\x03R\x11lastRechargedAtMs\x12*\n" +
"\x11gift_income_coins\x18\x16 \x01(\x03R\x0fgiftIncomeCoins\x121\n" +
"\x15user_registered_at_ms\x18\x17 \x01(\x03R\x12userRegisteredAtMs\"\xd3\x01\n" +
"\x15user_registered_at_ms\x18\x17 \x01(\x03R\x12userRegisteredAtMs\x12\x1f\n" +
"\vroom_locked\x18\x18 \x01(\bR\n" +
"roomLocked\"\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

@ -46,6 +46,8 @@ message LuckyGiftMeta {
// user_registered_at_ms user-service
// dynamic_v3 48 fail-close
int64 user_registered_at_ms = 23;
// room_locked Room Cell
bool room_locked = 24;
}
message LuckyGiftRuleTier {

View File

@ -7204,8 +7204,10 @@ type AdminRoomListItem struct {
SortScore int64 `protobuf:"varint,17,opt,name=sort_score,json=sortScore,proto3" json:"sort_score,omitempty"`
CreatedAtMs int64 `protobuf:"varint,18,opt,name=created_at_ms,json=createdAtMs,proto3" json:"created_at_ms,omitempty"`
UpdatedAtMs int64 `protobuf:"varint,19,opt,name=updated_at_ms,json=updatedAtMs,proto3" json:"updated_at_ms,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
// locked 只暴露是否需要入房密码,供内部展示投影生成可安全点击的房间入口。
Locked bool `protobuf:"varint,20,opt,name=locked,proto3" json:"locked,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *AdminRoomListItem) Reset() {
@ -7371,6 +7373,13 @@ func (x *AdminRoomListItem) GetUpdatedAtMs() int64 {
return 0
}
func (x *AdminRoomListItem) GetLocked() bool {
if x != nil {
return x.Locked
}
return false
}
type AdminListRoomsRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
Meta *RequestMeta `protobuf:"bytes,1,opt,name=meta,proto3" json:"meta,omitempty"`
@ -14527,7 +14536,7 @@ const file_proto_room_v1_room_proto_rawDesc = "" +
"\x06reason\x18\x02 \x01(\tR\x06reason\"z\n" +
"\x11CloseRoomResponse\x124\n" +
"\x06result\x18\x01 \x01(\v2\x1c.hyapp.room.v1.CommandResultR\x06result\x12/\n" +
"\x04room\x18\x02 \x01(\v2\x1b.hyapp.room.v1.RoomSnapshotR\x04room\"\x8f\x05\n" +
"\x04room\x18\x02 \x01(\v2\x1b.hyapp.room.v1.RoomSnapshotR\x04room\"\xa7\x05\n" +
"\x11AdminRoomListItem\x12\x17\n" +
"\aroom_id\x18\x01 \x01(\tR\x06roomId\x12\"\n" +
"\rroom_short_id\x18\x02 \x01(\tR\vroomShortId\x12*\n" +
@ -14551,7 +14560,8 @@ const file_proto_room_v1_room_proto_rawDesc = "" +
"\n" +
"sort_score\x18\x11 \x01(\x03R\tsortScore\x12\"\n" +
"\rcreated_at_ms\x18\x12 \x01(\x03R\vcreatedAtMs\x12\"\n" +
"\rupdated_at_ms\x18\x13 \x01(\x03R\vupdatedAtMs\"\xba\x02\n" +
"\rupdated_at_ms\x18\x13 \x01(\x03R\vupdatedAtMs\x12\x16\n" +
"\x06locked\x18\x14 \x01(\bR\x06locked\"\xba\x02\n" +
"\x15AdminListRoomsRequest\x12.\n" +
"\x04meta\x18\x01 \x01(\v2\x1a.hyapp.room.v1.RequestMetaR\x04meta\x12\x12\n" +
"\x04page\x18\x02 \x01(\x05R\x04page\x12\x1b\n" +

View File

@ -871,6 +871,8 @@ message AdminRoomListItem {
int64 sort_score = 17;
int64 created_at_ms = 18;
int64 updated_at_ms = 19;
// locked
bool locked = 20;
}
message AdminListRoomsRequest {

View File

@ -144,17 +144,23 @@ Explore 游戏 H5 的后台示例:
全服飘窗使用 IM custom message。App 会解析 `jump_type``jump_param``jump_payload`
指定房间的 `game_win``super_gift``lucky_gift_big_win` 必须在顶层携带布尔字段 `room_locked`。App 用该字段在首页点击飘屏时先弹出房间密码输入;真正是否允许进房仍以 Room Cell 的 `JoinRoom` 校验为准。
`jump_payload` 跳转到与中奖事件不同的房间时,`jump_payload.room_locked` 必须与该目标房间对应;否则使用顶层的 `room_locked` 作为默认值。
### 指定 jump_payload
```json
{
"broadcast_type": "game_win",
"room_id": "ROOM_ID",
"room_locked": true,
"game_id": "GAME_ID",
"coins": 1000,
"jump_type": "room_window",
"jump_payload": {
"room_id": "ROOM_ID",
"room_locked": true,
"window": "gift_panel",
"gift_id": "GIFT_ID"
}

View File

@ -73,6 +73,8 @@ type Client interface {
ListWheelDraws(ctx context.Context, req *activityv1.ListWheelDrawsRequest) (*activityv1.ListWheelDrawsResponse, error)
GetWheelDrawSummary(ctx context.Context, req *activityv1.GetWheelDrawSummaryRequest) (*activityv1.GetWheelDrawSummaryResponse, error)
RemoveRegionBroadcastMember(ctx context.Context, req *activityv1.RemoveRegionBroadcastMemberRequest) (*activityv1.RemoveRegionBroadcastMemberResponse, error)
GetGameWinBroadcastConfig(ctx context.Context, req *activityv1.GetGameWinBroadcastConfigRequest) (*activityv1.GetGameWinBroadcastConfigResponse, error)
UpdateGameWinBroadcastConfig(ctx context.Context, req *activityv1.UpdateGameWinBroadcastConfigRequest) (*activityv1.UpdateGameWinBroadcastConfigResponse, error)
ListLevelConfig(ctx context.Context, req *activityv1.ListLevelConfigRequest) (*activityv1.ListLevelConfigResponse, error)
UpsertLevelTrack(ctx context.Context, req *activityv1.UpsertLevelTrackRequest) (*activityv1.UpsertLevelTrackResponse, error)
UpsertLevelRule(ctx context.Context, req *activityv1.UpsertLevelRuleRequest) (*activityv1.UpsertLevelRuleResponse, error)
@ -98,6 +100,7 @@ type GRPCClient struct {
cpWeeklyRankClient activityv1.AdminCPWeeklyRankServiceClient
wheelClient activityv1.AdminWheelServiceClient
broadcastClient activityv1.BroadcastServiceClient
adminBroadcastClient activityv1.AdminBroadcastServiceClient
levelClient activityv1.GrowthLevelServiceClient
growthClient activityv1.AdminGrowthLevelServiceClient
messageClient activityv1.MessageInboxServiceClient
@ -119,6 +122,7 @@ func NewGRPC(conn grpc.ClientConnInterface) *GRPCClient {
cpWeeklyRankClient: activityv1.NewAdminCPWeeklyRankServiceClient(conn),
wheelClient: activityv1.NewAdminWheelServiceClient(conn),
broadcastClient: activityv1.NewBroadcastServiceClient(conn),
adminBroadcastClient: activityv1.NewAdminBroadcastServiceClient(conn),
levelClient: activityv1.NewGrowthLevelServiceClient(conn),
growthClient: activityv1.NewAdminGrowthLevelServiceClient(conn),
messageClient: activityv1.NewMessageInboxServiceClient(conn),
@ -377,6 +381,14 @@ func (c *GRPCClient) RemoveRegionBroadcastMember(ctx context.Context, req *activ
return c.broadcastClient.RemoveRegionBroadcastMember(ctx, req)
}
func (c *GRPCClient) GetGameWinBroadcastConfig(ctx context.Context, req *activityv1.GetGameWinBroadcastConfigRequest) (*activityv1.GetGameWinBroadcastConfigResponse, error) {
return c.adminBroadcastClient.GetGameWinBroadcastConfig(ctx, req)
}
func (c *GRPCClient) UpdateGameWinBroadcastConfig(ctx context.Context, req *activityv1.UpdateGameWinBroadcastConfigRequest) (*activityv1.UpdateGameWinBroadcastConfigResponse, error) {
return c.adminBroadcastClient.UpdateGameWinBroadcastConfig(ctx, req)
}
func (c *GRPCClient) ListLevelConfig(ctx context.Context, req *activityv1.ListLevelConfigRequest) (*activityv1.ListLevelConfigResponse, error) {
return c.growthClient.ListLevelConfig(ctx, req)
}

View File

@ -0,0 +1,100 @@
package appconfig
import (
"context"
"errors"
"fmt"
"time"
"hyapp-admin-server/internal/appctx"
"hyapp-admin-server/internal/middleware"
"hyapp-admin-server/internal/modules/shared"
"hyapp-admin-server/internal/response"
activityv1 "hyapp.local/api/proto/activity/v1"
"github.com/gin-gonic/gin"
)
type floatingScreenConfigRequest struct {
Enabled bool `json:"enabled"`
GameWinMinCoins int64 `json:"gameWinMinCoins" binding:"required,min=1,max=1000000000000"`
}
type floatingScreenConfigDTO struct {
AppCode string `json:"appCode"`
Enabled bool `json:"enabled"`
GameWinMinCoins int64 `json:"gameWinMinCoins"`
UpdatedByAdminID int64 `json:"updatedByAdminId"`
CreatedAtMS int64 `json:"createdAtMs"`
UpdatedAtMS int64 `json:"updatedAtMs"`
}
func (s *AppConfigService) GetFloatingScreenConfig(ctx context.Context, appCode string) (floatingScreenConfigDTO, error) {
if s == nil || s.activity == nil {
return floatingScreenConfigDTO{}, errors.New("activity broadcast config is unavailable")
}
appCode = appctx.Normalize(appCode)
resp, err := s.activity.GetGameWinBroadcastConfig(ctx, &activityv1.GetGameWinBroadcastConfigRequest{
Meta: &activityv1.RequestMeta{Caller: "admin-server", AppCode: appCode, SentAtMs: time.Now().UTC().UnixMilli()},
})
if err != nil {
return floatingScreenConfigDTO{}, err
}
return floatingScreenConfigFromProto(appCode, resp.GetConfig()), nil
}
func (s *AppConfigService) UpdateFloatingScreenConfig(ctx context.Context, appCode string, request floatingScreenConfigRequest, operatorAdminID int64) (floatingScreenConfigDTO, error) {
if s == nil || s.activity == nil {
return floatingScreenConfigDTO{}, errors.New("activity broadcast config is unavailable")
}
appCode = appctx.Normalize(appCode)
resp, err := s.activity.UpdateGameWinBroadcastConfig(ctx, &activityv1.UpdateGameWinBroadcastConfigRequest{
Meta: &activityv1.RequestMeta{Caller: "admin-server", AppCode: appCode, SentAtMs: time.Now().UTC().UnixMilli()},
Enabled: request.Enabled,
MinWinCoins: request.GameWinMinCoins,
OperatorAdminId: operatorAdminID,
})
if err != nil {
return floatingScreenConfigDTO{}, err
}
return floatingScreenConfigFromProto(appCode, resp.GetConfig()), nil
}
func floatingScreenConfigFromProto(appCode string, config *activityv1.GameWinBroadcastConfig) floatingScreenConfigDTO {
if config == nil {
return floatingScreenConfigDTO{AppCode: appctx.Normalize(appCode)}
}
return floatingScreenConfigDTO{
AppCode: appctx.Normalize(appCode),
Enabled: config.GetEnabled(),
GameWinMinCoins: config.GetMinWinCoins(),
UpdatedByAdminID: config.GetUpdatedByAdminId(),
CreatedAtMS: config.GetCreatedAtMs(),
UpdatedAtMS: config.GetUpdatedAtMs(),
}
}
func (h *Handler) GetFloatingScreenConfig(c *gin.Context) {
item, err := h.service.GetFloatingScreenConfig(c.Request.Context(), appctx.FromContext(c.Request.Context()))
if err != nil {
response.ServerError(c, "获取飘屏配置失败")
return
}
response.OK(c, item)
}
func (h *Handler) UpdateFloatingScreenConfig(c *gin.Context) {
var request floatingScreenConfigRequest
if err := c.ShouldBindJSON(&request); err != nil {
response.BadRequest(c, "中奖金币阈值必须在 1 到 1000000000000 之间")
return
}
item, err := h.service.UpdateFloatingScreenConfig(c.Request.Context(), appctx.FromContext(c.Request.Context()), request, int64(middleware.CurrentUserID(c)))
if err != nil {
response.BadRequest(c, err.Error())
return
}
// 审计记录明确保留 App、启停和阈值便于定位一次运营调整为何改变了实时飘屏数量。
shared.OperationLog(c, h.audit, "update-game-win-floating-screen", "game_win_broadcast_configs", "success", fmt.Sprintf("app_code=%s enabled=%t min_win_coins=%d", item.AppCode, item.Enabled, item.GameWinMinCoins))
response.OK(c, item)
}

View File

@ -18,6 +18,8 @@ func RegisterRoutes(protected *gin.RouterGroup, h *Handler) {
protected.DELETE("/admin/app-config/h5-links/:key", middleware.RequirePermission("app-config:update"), h.DeleteH5Link)
protected.GET("/admin/app-config/gift-combo", middleware.RequirePermission("app-config:view"), h.GetGiftComboConfig)
protected.PUT("/admin/app-config/gift-combo", middleware.RequirePermission("app-config:update"), h.UpdateGiftComboConfig)
protected.GET("/admin/app-config/floating-screen", middleware.RequirePermission("app-config:view"), h.GetFloatingScreenConfig)
protected.PUT("/admin/app-config/floating-screen", middleware.RequirePermission("app-config:update"), h.UpdateFloatingScreenConfig)
protected.GET("/admin/app-config/explore-tabs", middleware.RequirePermission("app-config:view"), h.ListExploreTabs)
protected.POST("/admin/app-config/explore-tabs", middleware.RequirePermission("app-config:update"), h.CreateExploreTab)

View File

@ -7,6 +7,7 @@ import (
"time"
"hyapp-admin-server/internal/model"
activityv1 "hyapp.local/api/proto/activity/v1"
)
func TestH5AppScopeSchemaMigrationCreatesScopedKeys(t *testing.T) {
@ -53,6 +54,28 @@ func TestH5StrictAppScopeMigrationMovesLegacyLinksToDefaultApp(t *testing.T) {
}
}
func TestFloatingScreenConfigMapsActivityOwnerContract(t *testing.T) {
item := floatingScreenConfigFromProto(" FAMI ", &activityv1.GameWinBroadcastConfig{
Enabled: true, MinWinCoins: 9000, UpdatedByAdminId: 7, CreatedAtMs: 100, UpdatedAtMs: 200,
})
if item.AppCode != "fami" || !item.Enabled || item.GameWinMinCoins != 9000 || item.UpdatedByAdminID != 7 || item.UpdatedAtMS != 200 {
t.Fatalf("floating screen config mapping mismatch: %+v", item)
}
}
func TestFloatingScreenNavigationUsesAppConfigChildMenu(t *testing.T) {
body, err := os.ReadFile("../../../migrations/114_game_win_floating_screen_navigation.sql")
if err != nil {
t.Fatalf("read floating screen navigation migration: %v", err)
}
sqlText := string(body)
for _, snippet := range []string{"'飘屏配置'", "'app-config-floating-screen'", "'/app-config/floating-screen'", "parent.code = 'app-config'", "'app-config:view'"} {
if !strings.Contains(sqlText, snippet) {
t.Fatalf("floating screen navigation migration missing %q", snippet)
}
}
}
func TestH5LinkModelFromPayloadValidatesDynamicConfig(t *testing.T) {
item, err := h5LinkModelFromPayload(" YUMI ", h5LinkPayload{
Key: "host-center.v2",

View File

@ -0,0 +1,16 @@
-- 飘屏配置复用 APP 配置读写权限;阈值事实由 activity-service 按 app_code 持久化,后台库只保存导航。
SET @now_ms := CAST(UNIX_TIMESTAMP(CURRENT_TIMESTAMP(3)) * 1000 AS UNSIGNED);
INSERT INTO admin_menus (parent_id, title, code, path, icon, permission_code, sort, visible, created_at_ms, updated_at_ms)
SELECT parent.id, '飘屏配置', 'app-config-floating-screen', '/app-config/floating-screen', 'campaign', 'app-config:view', 71, TRUE, @now_ms, @now_ms
FROM admin_menus parent
WHERE parent.code = 'app-config'
ON DUPLICATE KEY UPDATE
parent_id = VALUES(parent_id),
title = VALUES(title),
path = VALUES(path),
icon = VALUES(icon),
permission_code = VALUES(permission_code),
sort = VALUES(sort),
visible = VALUES(visible),
updated_at_ms = @now_ms;

View File

@ -66,6 +66,8 @@ broadcast:
enabled: true
app_codes: ["lalu", "fami", "huwaa"]
super_gift_min_value: 100000
game_win_min_coins: 100000
game_win_config_cache_ttl: "5s"
worker_poll_interval: "1s"
worker_batch_size: 100
worker_lock_ttl: "30s"
@ -91,6 +93,11 @@ rocketmq:
topic: "hyapp_lucky_gift_outbox"
consumer_group: "hyapp-activity-lucky-gift-outbox"
consumer_max_reconsume_times: 16
game_outbox:
enabled: true
topic: "hyapp_game_outbox"
consumer_group: "hyapp-activity-game-win-outbox"
consumer_max_reconsume_times: 16
wallet_outbox:
enabled: true
topic: "hyapp_wallet_outbox"

View File

@ -67,6 +67,8 @@ broadcast:
# 共享腾讯 IM SDKAppID 时必须显式列出全部业务租户,确保空数据 App 也能预建播报群。
app_codes: ["lalu", "fami", "huwaa"]
super_gift_min_value: 100000
game_win_min_coins: 100000
game_win_config_cache_ttl: "5s"
worker_poll_interval: "1s"
worker_batch_size: 100
worker_lock_ttl: "30s"
@ -92,6 +94,11 @@ rocketmq:
topic: "hyapp_lucky_gift_outbox"
consumer_group: "hyapp-activity-lucky-gift-outbox"
consumer_max_reconsume_times: 16
game_outbox:
enabled: true
topic: "hyapp_game_outbox"
consumer_group: "hyapp-activity-game-win-outbox"
consumer_max_reconsume_times: 16
wallet_outbox:
enabled: true
topic: "hyapp_wallet_outbox"

View File

@ -67,6 +67,8 @@ broadcast:
# 同一套腾讯 IM 云应用承载多个业务 App群 ID 和 outbox 仍按 app_code 隔离。
app_codes: ["lalu", "fami", "huwaa"]
super_gift_min_value: 100000
game_win_min_coins: 100000
game_win_config_cache_ttl: "5s"
worker_poll_interval: "1s"
worker_batch_size: 100
worker_lock_ttl: "30s"
@ -92,6 +94,11 @@ rocketmq:
topic: "hyapp_lucky_gift_outbox"
consumer_group: "hyapp-activity-lucky-gift-outbox"
consumer_max_reconsume_times: 16
game_outbox:
enabled: true
topic: "hyapp_game_outbox"
consumer_group: "hyapp-activity-game-win-outbox"
consumer_max_reconsume_times: 16
wallet_outbox:
enabled: true
topic: "hyapp_wallet_outbox"

View File

@ -196,6 +196,16 @@ CREATE TABLE IF NOT EXISTS im_broadcast_outbox (
KEY idx_im_broadcast_outbox_retention (app_code, status, updated_at_ms, event_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='IM 广播发件箱表';
CREATE TABLE IF NOT EXISTS game_win_broadcast_configs (
app_code VARCHAR(32) NOT NULL COMMENT '应用编码,用于多租户隔离',
enabled TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否发送游戏中奖飘屏',
min_win_coins BIGINT NOT NULL COMMENT '单次返奖达到该金币数才发送飘屏',
updated_by_admin_id BIGINT NOT NULL DEFAULT 0 COMMENT '最后更新管理员 ID',
created_at_ms BIGINT NOT NULL COMMENT '创建时间UTC epoch ms',
updated_at_ms BIGINT NOT NULL COMMENT '更新时间UTC epoch ms',
PRIMARY KEY (app_code)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='游戏中奖飘屏配置';
CREATE TABLE IF NOT EXISTS task_reward_policy_instances (
app_code VARCHAR(32) NOT NULL COMMENT '应用编码',
instance_code VARCHAR(96) NOT NULL COMMENT '后台策略实例编码',

View File

@ -0,0 +1,12 @@
SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci;
-- 新表只有按 app_code 主键点查和单行 upsert不扫描或改写既有高频活动流水表。
CREATE TABLE IF NOT EXISTS game_win_broadcast_configs (
app_code VARCHAR(32) NOT NULL COMMENT '应用编码,用于多租户隔离',
enabled TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否发送游戏中奖飘屏',
min_win_coins BIGINT NOT NULL COMMENT '单次返奖达到该金币数才发送飘屏',
updated_by_admin_id BIGINT NOT NULL DEFAULT 0 COMMENT '最后更新管理员 ID',
created_at_ms BIGINT NOT NULL COMMENT '创建时间UTC epoch ms',
updated_at_ms BIGINT NOT NULL COMMENT '更新时间UTC epoch ms',
PRIMARY KEY (app_code)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='游戏中奖飘屏配置';

View File

@ -41,6 +41,7 @@ func registerGRPCServers(server *grpc.Server, services *serviceBundle) {
broadcastServer := grpcserver.NewBroadcastServer(services.broadcast, services.growth, services.weeklyStar, services.task, services.roomTurnoverReward, services.agencyOpening, services.activityTemplate)
activityv1.RegisterBroadcastServiceServer(server, broadcastServer)
activityv1.RegisterRoomEventConsumerServiceServer(server, broadcastServer)
activityv1.RegisterAdminBroadcastServiceServer(server, broadcastServer)
activityv1.RegisterFirstRechargeRewardServiceServer(server, grpcserver.NewFirstRechargeRewardServer(services.firstRechargeReward))
activityv1.RegisterAdminFirstRechargeRewardServiceServer(server, grpcserver.NewAdminFirstRechargeRewardServer(services.firstRechargeReward))
activityv1.RegisterCumulativeRechargeRewardServiceServer(server, grpcserver.NewCumulativeRechargeRewardServer(services.cumulativeRecharge))

View File

@ -8,6 +8,7 @@ import (
"time"
"hyapp/pkg/appcode"
"hyapp/pkg/gamemq"
"hyapp/pkg/luckygiftmq"
"hyapp/pkg/rocketmqx"
"hyapp/pkg/roommq"
@ -19,7 +20,7 @@ import (
)
func buildMQConsumers(cfg config.Config, services *serviceBundle) ([]*rocketmqx.Consumer, error) {
mqConsumers := make([]*rocketmqx.Consumer, 0, 4)
mqConsumers := make([]*rocketmqx.Consumer, 0, 5)
if cfg.RocketMQ.RoomOutbox.Enabled {
consumer, err := newRoomOutboxConsumer(cfg, services)
if err != nil {
@ -42,6 +43,14 @@ func buildMQConsumers(cfg config.Config, services *serviceBundle) ([]*rocketmqx.
}
mqConsumers = append(mqConsumers, consumer)
}
if cfg.RocketMQ.GameOutbox.Enabled {
consumer, err := newGameOutboxConsumer(cfg, services)
if err != nil {
shutdownConsumers(mqConsumers)
return nil, err
}
mqConsumers = append(mqConsumers, consumer)
}
if cfg.RocketMQ.UserOutbox.Enabled && !services.broadcastPublisherAvailable {
if !cfg.TaskEventWorker.Enabled {
shutdownConsumers(mqConsumers)
@ -247,6 +256,29 @@ func newLuckyGiftOutboxConsumer(cfg config.Config, services *serviceBundle) (*ro
return consumer, nil
}
func newGameOutboxConsumer(cfg config.Config, services *serviceBundle) (*rocketmqx.Consumer, error) {
consumer, err := rocketmqx.NewConsumer(gameOutboxConsumerConfig(cfg.RocketMQ))
if err != nil {
return nil, err
}
if err := consumer.Subscribe(cfg.RocketMQ.GameOutbox.Topic, gamemq.TagGameOutboxEvent, func(ctx context.Context, message rocketmqx.ConsumedMessage) error {
outboxMessage, err := gamemq.DecodeGameOutboxMessage(message.Body)
if err != nil {
return err
}
eventCtx := appcode.WithContext(ctx, outboxMessage.AppCode)
if _, err := services.broadcast.HandleGameEvent(eventCtx, outboxMessage); err != nil {
// game_win 与其他投影使用独立 group返回错误保留至少一次重试event_id 主键负责 IM outbox 幂等。
return fmt.Errorf("game outbox broadcast projection: %w", err)
}
return nil
}); err != nil {
_ = consumer.Shutdown()
return nil, err
}
return consumer, nil
}
func newUserOutboxConsumer(cfg config.Config, broadcastSvc *broadcastservice.Service) (*rocketmqx.Consumer, error) {
consumer, err := rocketmqx.NewConsumer(userOutboxConsumerConfig(cfg.RocketMQ))
if err != nil {
@ -495,6 +527,11 @@ func luckyGiftOutboxConsumerConfig(cfg config.RocketMQConfig) rocketmqx.Consumer
return rocketMQConsumerConfig(cfg, cfg.LuckyGiftOutbox.ConsumerGroup, cfg.LuckyGiftOutbox.ConsumerMaxReconsumeTimes)
}
func gameOutboxConsumerConfig(cfg config.RocketMQConfig) rocketmqx.ConsumerConfig {
// 中奖飘屏是不可批量历史重放的实时副作用,新消费组沿用 latest-offset 默认值。
return rocketMQConsumerConfig(cfg, cfg.GameOutbox.ConsumerGroup, cfg.GameOutbox.ConsumerMaxReconsumeTimes)
}
func userOutboxConsumerConfig(cfg config.RocketMQConfig) rocketmqx.ConsumerConfig {
return rocketMQConsumerConfig(cfg, cfg.UserOutbox.ConsumerGroup, cfg.UserOutbox.ConsumerMaxReconsumeTimes)
}

View File

@ -50,6 +50,22 @@ func TestLuckyGiftOutboxConsumerUsesIndependentRealtimeGroup(t *testing.T) {
}
}
func TestGameOutboxConsumerUsesIndependentRealtimeGroup(t *testing.T) {
cfg := config.Default().RocketMQ
cfg.GameOutbox.ConsumerGroup = "activity-game-win-test"
consumerConfig := gameOutboxConsumerConfig(cfg)
if consumerConfig.GroupName != cfg.GameOutbox.ConsumerGroup {
t.Fatalf("game outbox group = %q, want %q", consumerConfig.GroupName, cfg.GameOutbox.ConsumerGroup)
}
if consumerConfig.GroupName == cfg.RoomOutbox.ConsumerGroup || consumerConfig.GroupName == cfg.LuckyGiftOutbox.ConsumerGroup {
t.Fatalf("game_win must own an independent MQ offset: %+v", consumerConfig)
}
if consumerConfig.ConsumeFromFirst {
t.Fatal("game_win consumer must not replay historical payouts for a new group")
}
}
func TestActivityRoomSelectorKeepsLegacyAndOnlyRelevantTypedEvents(t *testing.T) {
expression, err := roommq.LegacyCompatibleTagExpression(activityRoomEventTypes()...)
if err != nil {

View File

@ -95,6 +95,8 @@ func buildServiceBundle(cfg config.Config, repository *mysqlstorage.Repository,
NodeID: cfg.NodeID,
AppCodes: cfg.Broadcast.AppCodes,
SuperGiftMinValue: cfg.Broadcast.SuperGiftMinValue,
GameWinMinCoins: cfg.Broadcast.GameWinMinCoins,
GameWinConfigCacheTTL: cfg.Broadcast.GameWinConfigCacheTTL,
WorkerBatchSize: cfg.Broadcast.WorkerBatchSize,
WorkerLockTTL: cfg.Broadcast.WorkerLockTTL,
WorkerMaxRetry: cfg.Broadcast.WorkerMaxRetry,
@ -105,6 +107,7 @@ func buildServiceBundle(cfg config.Config, repository *mysqlstorage.Repository,
}, repository, broadcastPublisher, regionSource)
userProfileSource := activityclient.NewGRPCUserProfileSource(clients.userConn)
broadcastSvc.SetSenderProfileSource(userProfileSource)
broadcastSvc.SetRoomSnapshotSource(activityclient.NewGRPCRoomBroadcastSource(clients.roomConn))
wheelSvc := wheelservice.New(repository, wheelservice.WithWallet(walletClient))
firstRechargeRewardSvc := firstrechargeservice.New(repository, walletClient)
cumulativeRechargeSvc := cumulativerechargeservice.New(repository, walletClient)

View File

@ -0,0 +1,48 @@
package client
import (
"context"
"strings"
"time"
"google.golang.org/grpc"
roomv1 "hyapp.local/api/proto/room/v1"
"hyapp/pkg/appcode"
broadcastservice "hyapp/services/activity-service/internal/service/broadcast"
)
// GRPCRoomBroadcastSource 通过 room-service 的 owner 读模型补齐 game_win 跳转所需展示状态。
// activity-service 不缓存密码或房间状态,只把本次结算时读取到的 locked 快照固化进 IM payload。
type GRPCRoomBroadcastSource struct {
client roomv1.RoomQueryServiceClient
}
func NewGRPCRoomBroadcastSource(conn *grpc.ClientConn) *GRPCRoomBroadcastSource {
return &GRPCRoomBroadcastSource{client: roomv1.NewRoomQueryServiceClient(conn)}
}
func (s *GRPCRoomBroadcastSource) GetRoomBroadcastSnapshot(ctx context.Context, roomID string) (broadcastservice.RoomBroadcastSnapshot, error) {
roomID = strings.TrimSpace(roomID)
resp, err := s.client.AdminGetRoom(ctx, &roomv1.AdminGetRoomRequest{
Meta: &roomv1.RequestMeta{
RequestId: "activity-game-win-room:" + roomID,
AppCode: appcode.FromContext(ctx),
SentAtMs: time.Now().UTC().UnixMilli(),
},
RoomId: roomID,
})
if err != nil {
return broadcastservice.RoomBroadcastSnapshot{}, err
}
room := resp.GetRoom()
if room == nil {
return broadcastservice.RoomBroadcastSnapshot{RoomID: roomID}, nil
}
return broadcastservice.RoomBroadcastSnapshot{
RoomID: room.GetRoomId(),
Title: room.GetTitle(),
Locked: room.GetLocked(),
VisibleRegionID: room.GetVisibleRegionId(),
Status: room.GetStatus(),
}, nil
}

View File

@ -184,8 +184,11 @@ type BroadcastConfig struct {
Enabled bool `yaml:"enabled"`
// AppCodes 是共享腾讯 IM 应用内需要主动维护播报群的业务租户清单。
// outbox 发现只能覆盖已经产生过消息的 App新 App 上线前必须显式列入,才能提前建好全局/区域群。
AppCodes []string `yaml:"app_codes"`
SuperGiftMinValue int64 `yaml:"super_gift_min_value"`
AppCodes []string `yaml:"app_codes"`
SuperGiftMinValue int64 `yaml:"super_gift_min_value"`
// GameWinMinCoins 是数据库尚未写入 App 级策略时的安全默认值;后台保存后以持久化配置为准。
GameWinMinCoins int64 `yaml:"game_win_min_coins"`
GameWinConfigCacheTTL time.Duration `yaml:"game_win_config_cache_ttl"`
WorkerPollInterval time.Duration `yaml:"worker_poll_interval"`
WorkerBatchSize int `yaml:"worker_batch_size"`
WorkerLockTTL time.Duration `yaml:"worker_lock_ttl"`
@ -208,6 +211,7 @@ type RocketMQConfig struct {
VIPChannel bool `yaml:"vip_channel"`
RoomOutbox RoomOutboxMQConfig `yaml:"room_outbox"`
LuckyGiftOutbox LuckyGiftOutboxMQConfig `yaml:"lucky_gift_outbox"`
GameOutbox GameOutboxMQConfig `yaml:"game_outbox"`
WalletOutbox WalletOutboxMQConfig `yaml:"wallet_outbox"`
UserOutbox UserOutboxMQConfig `yaml:"user_outbox"`
MessageActionOutbox MessageActionOutboxMQConfig `yaml:"message_action_outbox"`
@ -232,6 +236,14 @@ type LuckyGiftOutboxMQConfig struct {
ConsumerMaxReconsumeTimes int32 `yaml:"consumer_max_reconsume_times"`
}
// GameOutboxMQConfig 为 game_win 实时副作用保留独立消费位点,不能与统计 consumer group 共用。
type GameOutboxMQConfig struct {
Enabled bool `yaml:"enabled"`
Topic string `yaml:"topic"`
ConsumerGroup string `yaml:"consumer_group"`
ConsumerMaxReconsumeTimes int32 `yaml:"consumer_max_reconsume_times"`
}
// WalletOutboxMQConfig 控制 activity 对 wallet_outbox topic 的独立消费组。
type WalletOutboxMQConfig struct {
Enabled bool `yaml:"enabled"`
@ -337,6 +349,8 @@ func Default() Config {
Enabled: false,
AppCodes: []string{"lalu", "fami", "huwaa"},
SuperGiftMinValue: 100000,
GameWinMinCoins: 100000,
GameWinConfigCacheTTL: 5 * time.Second,
WorkerPollInterval: time.Second,
WorkerBatchSize: 100,
WorkerLockTTL: 30 * time.Second,
@ -377,6 +391,12 @@ func defaultRocketMQConfig() RocketMQConfig {
ConsumerGroup: "hyapp-activity-lucky-gift-outbox",
ConsumerMaxReconsumeTimes: 16,
},
GameOutbox: GameOutboxMQConfig{
Enabled: false,
Topic: "hyapp_game_outbox",
ConsumerGroup: "hyapp-activity-game-win-outbox",
ConsumerMaxReconsumeTimes: 16,
},
WalletOutbox: WalletOutboxMQConfig{
Enabled: false,
Topic: "hyapp_wallet_outbox",
@ -470,6 +490,12 @@ func Load(path string) (Config, error) {
if cfg.Broadcast.SuperGiftMinValue <= 0 {
cfg.Broadcast.SuperGiftMinValue = 100000
}
if cfg.Broadcast.GameWinMinCoins <= 0 {
cfg.Broadcast.GameWinMinCoins = 100000
}
if cfg.Broadcast.GameWinConfigCacheTTL <= 0 {
cfg.Broadcast.GameWinConfigCacheTTL = 5 * time.Second
}
cfg.Broadcast.AppCodes = normalizeAppCodes(cfg.Broadcast.AppCodes)
if len(cfg.Broadcast.AppCodes) == 0 {
cfg.Broadcast.AppCodes = append([]string(nil), Default().Broadcast.AppCodes...)
@ -544,6 +570,9 @@ func Load(path string) (Config, error) {
// 因此不能让 lucky consumer 以“已开启”配置启动成只进不出的半链路。
return Config{}, errors.New("rocketmq.lucky_gift_outbox.enabled requires broadcast.enabled and tencent_im.enabled")
}
if cfg.RocketMQ.GameOutbox.Enabled && (!cfg.Broadcast.Enabled || !cfg.TencentIM.Enabled) {
return Config{}, errors.New("rocketmq.game_outbox.enabled requires broadcast.enabled and tencent_im.enabled")
}
if (cfg.FirstRechargeRewardWorker.Enabled || cfg.CumulativeRechargeRewardWorker.Enabled || cfg.InviteActivityRewardWorker.Enabled || cfg.RedPacketBroadcastWorker.Enabled || cfg.UserLeaderboardWorker.Enabled) && !cfg.RocketMQ.WalletOutbox.Enabled {
return Config{}, errors.New("wallet outbox workers require rocketmq.wallet_outbox.enabled")
}
@ -608,6 +637,15 @@ func normalizeRocketMQConfig(cfg RocketMQConfig) (RocketMQConfig, error) {
if cfg.LuckyGiftOutbox.ConsumerMaxReconsumeTimes <= 0 {
cfg.LuckyGiftOutbox.ConsumerMaxReconsumeTimes = defaults.LuckyGiftOutbox.ConsumerMaxReconsumeTimes
}
if cfg.GameOutbox.Topic = strings.TrimSpace(cfg.GameOutbox.Topic); cfg.GameOutbox.Topic == "" {
cfg.GameOutbox.Topic = defaults.GameOutbox.Topic
}
if cfg.GameOutbox.ConsumerGroup = strings.TrimSpace(cfg.GameOutbox.ConsumerGroup); cfg.GameOutbox.ConsumerGroup == "" {
cfg.GameOutbox.ConsumerGroup = defaults.GameOutbox.ConsumerGroup
}
if cfg.GameOutbox.ConsumerMaxReconsumeTimes <= 0 {
cfg.GameOutbox.ConsumerMaxReconsumeTimes = defaults.GameOutbox.ConsumerMaxReconsumeTimes
}
if cfg.WalletOutbox.Topic = strings.TrimSpace(cfg.WalletOutbox.Topic); cfg.WalletOutbox.Topic == "" {
cfg.WalletOutbox.Topic = defaults.WalletOutbox.Topic
}
@ -678,7 +716,7 @@ func normalizeRocketMQConfig(cfg RocketMQConfig) (RocketMQConfig, error) {
if cfg.ActivityTemplateOutbox.Retry < 0 {
cfg.ActivityTemplateOutbox.Retry = defaults.ActivityTemplateOutbox.Retry
}
if cfg.RoomOutbox.Enabled || cfg.LuckyGiftOutbox.Enabled || cfg.WalletOutbox.Enabled || cfg.UserOutbox.Enabled || cfg.MessageActionOutbox.Enabled || cfg.ActivityTemplateOutbox.Enabled {
if cfg.RoomOutbox.Enabled || cfg.LuckyGiftOutbox.Enabled || cfg.GameOutbox.Enabled || cfg.WalletOutbox.Enabled || cfg.UserOutbox.Enabled || cfg.MessageActionOutbox.Enabled || cfg.ActivityTemplateOutbox.Enabled {
cfg.Enabled = true
}
if cfg.Enabled {

View File

@ -22,6 +22,8 @@ const (
TypeRoomPasswordChanged = "room_password_changed"
// TypeLuckyGiftBigWin 是幸运礼物中奖区域飘屏;当前只让 10 倍及以上大奖进入播报 outbox。
TypeLuckyGiftBigWin = "lucky_gift_big_win"
// TypeGameWin 是游戏返奖达到运营阈值后的区域飘屏,来源只能是 game-service 已提交的结算事实。
TypeGameWin = "game_win"
// TypeVIPOnlineNotice 是 VIP 用户进入 App 时的全局飘屏。是否允许发送必须由 gateway
// 先向 wallet-service 校验 effective benefitactivity-service 只负责持久化和 IM 投递。
TypeVIPOnlineNotice = "vip_online_notice"
@ -92,6 +94,25 @@ type ConsumeLuckyGiftEventResult struct {
BroadcastCreated bool
}
// ConsumeGameEventResult 描述 GameOrderSettled 是否生成 game_win 区域飘屏。
// 来源 event_id 与播报投影 event_id 分开保存,便于定位 MQ 已消费但被阈值跳过的情况。
type ConsumeGameEventResult struct {
EventID string
Status string
BroadcastEventID string
BroadcastCreated bool
}
// GameWinConfig 是 activity-service 按 App 持久化的游戏中奖展示策略。
// 金额单位与 GameOrderSettled.coin_amount 一致,都是整数 COIN 最小展示单位。
type GameWinConfig struct {
Enabled bool
MinWinCoins int64
UpdatedByAdminID int64
CreatedAtMS int64
UpdatedAtMS int64
}
// RedPacketWalletEvent 是 wallet-service 已提交的红包资金事实。
type RedPacketWalletEvent struct {
AppCode string

View File

@ -0,0 +1,290 @@
package broadcast
import (
"context"
"encoding/json"
"fmt"
"strconv"
"strings"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
"hyapp/pkg/appcode"
"hyapp/pkg/gamemq"
"hyapp/pkg/xerr"
broadcastdomain "hyapp/services/activity-service/internal/domain/broadcast"
)
const maxGameWinMinCoins = int64(1_000_000_000_000)
type cachedGameWinConfig struct {
config broadcastdomain.GameWinConfig
expiresAtMS int64
}
// HandleGameEvent 把 game-service 已提交的返奖事实投影成区域 game_win 飘屏。
// 游戏结算、阈值策略、房间锁状态和 IM 投递分别由 owner 服务持有;这里仅在消费时组装稳定展示快照。
func (s *Service) HandleGameEvent(ctx context.Context, message gamemq.GameOutboxMessage) (broadcastdomain.ConsumeGameEventResult, error) {
result := broadcastdomain.ConsumeGameEventResult{EventID: strings.TrimSpace(message.EventID), Status: broadcastdomain.StatusSkipped}
if strings.TrimSpace(message.EventType) != gamemq.EventTypeGameOrderSettled {
return result, nil
}
opType := strings.ToLower(strings.TrimSpace(message.OpType))
if opType != "credit" && opType != "payout" {
// debit/bet/refund 不是中奖到账,确认 MQ 位点但绝不能生成正向中奖飘屏。
return result, nil
}
if strings.TrimSpace(message.AppCode) == "" || strings.TrimSpace(message.EventID) == "" || strings.TrimSpace(message.OrderID) == "" || message.UserID <= 0 || message.CoinAmount < 0 {
return broadcastdomain.ConsumeGameEventResult{}, xerr.New(xerr.InvalidArgument, "game order settled fact is incomplete")
}
eventCtx := appcode.WithContext(ctx, message.AppCode)
config, err := s.cachedGameWinBroadcastConfig(eventCtx)
if err != nil {
return broadcastdomain.ConsumeGameEventResult{}, err
}
if !config.Enabled || message.CoinAmount < config.MinWinCoins {
return result, nil
}
payload, err := decodeGameWinSourcePayload(message.PayloadJSON)
if err != nil {
return broadcastdomain.ConsumeGameEventResult{}, err
}
regionID := firstGamePayloadInt64(payload, "visible_region_id", "region_id")
if regionID <= 0 {
// 区域未知时不能扩大成全局播报;来源事实仍被合法确认,避免坏数据反复进入 DLQ。
return result, nil
}
profile, err := s.broadcastUserProfile(eventCtx, message.UserID)
if err != nil {
return broadcastdomain.ConsumeGameEventResult{}, fmt.Errorf("resolve game win user profile: %w", err)
}
roomID := firstGamePayloadString(payload, "room_id")
roomSnapshot := RoomBroadcastSnapshot{}
if roomID != "" {
if s.roomSnapshotSource == nil {
return broadcastdomain.ConsumeGameEventResult{}, xerr.New(xerr.Unavailable, "room snapshot source is not configured")
}
roomSnapshot, err = s.roomSnapshotSource.GetRoomBroadcastSnapshot(eventCtx, roomID)
if err != nil {
if status.Code(err) == codes.NotFound {
// 房间已删除时仍可展示中奖,但必须移除失效入口,不能把未知 locked=false 发给客户端。
roomID = ""
roomSnapshot = RoomBroadcastSnapshot{}
} else {
return broadcastdomain.ConsumeGameEventResult{}, fmt.Errorf("resolve game win room snapshot: %w", err)
}
}
}
broadcastEventID := "game_win:" + strings.TrimSpace(message.OrderID)
payloadJSON, err := gameWinPayload(message, payload, broadcastEventID, regionID, profile, roomID, roomSnapshot, s.now().UTC().UnixMilli())
if err != nil {
return broadcastdomain.ConsumeGameEventResult{}, err
}
published, err := s.PublishRegionBroadcast(eventCtx, PublishInput{
EventID: broadcastEventID,
BroadcastType: broadcastdomain.TypeGameWin,
RegionID: regionID,
PayloadJSON: payloadJSON,
})
if err != nil {
return broadcastdomain.ConsumeGameEventResult{}, err
}
return broadcastdomain.ConsumeGameEventResult{
EventID: message.EventID,
Status: published.Status,
BroadcastEventID: published.EventID,
BroadcastCreated: published.Created,
}, nil
}
// GetGameWinBroadcastConfig 直读当前 App 配置并刷新本实例缓存,后台读取不会拿到另一个副本尚未过期的旧值。
func (s *Service) GetGameWinBroadcastConfig(ctx context.Context) (broadcastdomain.GameWinConfig, error) {
if s == nil || s.gameWinConfigRepo == nil {
return broadcastdomain.GameWinConfig{}, xerr.New(xerr.Unavailable, "game win broadcast config repository is not configured")
}
config, exists, err := s.gameWinConfigRepo.GetGameWinBroadcastConfig(ctx)
if err != nil {
return broadcastdomain.GameWinConfig{}, err
}
if !exists {
config = broadcastdomain.GameWinConfig{Enabled: true, MinWinCoins: s.cfg.GameWinMinCoins}
}
s.storeCachedGameWinConfig(appcode.FromContext(ctx), config)
return config, nil
}
// UpdateGameWinBroadcastConfig 保存完整替换策略enabled=false 是显式停发,不能被省略值自动恢复为 true。
func (s *Service) UpdateGameWinBroadcastConfig(ctx context.Context, enabled bool, minWinCoins int64, operatorAdminID int64) (broadcastdomain.GameWinConfig, error) {
if s == nil || s.gameWinConfigRepo == nil {
return broadcastdomain.GameWinConfig{}, xerr.New(xerr.Unavailable, "game win broadcast config repository is not configured")
}
if minWinCoins <= 0 || minWinCoins > maxGameWinMinCoins {
return broadcastdomain.GameWinConfig{}, xerr.New(xerr.InvalidArgument, "min_win_coins must be between 1 and 1000000000000")
}
if operatorAdminID < 0 {
return broadcastdomain.GameWinConfig{}, xerr.New(xerr.InvalidArgument, "operator_admin_id is invalid")
}
nowMS := s.now().UTC().UnixMilli()
stored, err := s.gameWinConfigRepo.UpsertGameWinBroadcastConfig(ctx, broadcastdomain.GameWinConfig{
Enabled: enabled,
MinWinCoins: minWinCoins,
UpdatedByAdminID: operatorAdminID,
CreatedAtMS: nowMS,
UpdatedAtMS: nowMS,
})
if err != nil {
return broadcastdomain.GameWinConfig{}, err
}
s.storeCachedGameWinConfig(appcode.FromContext(ctx), stored)
return stored, nil
}
func (s *Service) cachedGameWinBroadcastConfig(ctx context.Context) (broadcastdomain.GameWinConfig, error) {
app := appcode.FromContext(ctx)
nowMS := s.now().UTC().UnixMilli()
s.gameWinConfigMu.RLock()
cached, ok := s.gameWinConfigCache[app]
s.gameWinConfigMu.RUnlock()
if ok && cached.expiresAtMS > nowMS {
return cached.config, nil
}
return s.GetGameWinBroadcastConfig(ctx)
}
func (s *Service) storeCachedGameWinConfig(app string, config broadcastdomain.GameWinConfig) {
if s == nil {
return
}
app = appcode.Normalize(app)
s.gameWinConfigMu.Lock()
s.gameWinConfigCache[app] = cachedGameWinConfig{
config: config,
expiresAtMS: s.now().UTC().Add(s.cfg.GameWinConfigCacheTTL).UnixMilli(),
}
s.gameWinConfigMu.Unlock()
}
func gameWinPayload(message gamemq.GameOutboxMessage, source map[string]any, eventID string, regionID int64, profile SenderProfile, roomID string, room RoomBroadcastSnapshot, sentAtMS int64) (string, error) {
gameID := strings.TrimSpace(message.GameID)
providerGameID := firstGamePayloadString(source, "provider_game_id")
gameName := firstGamePayloadString(source, "game_name")
gameIconURL := firstGamePayloadString(source, "game_icon_url", "game_cover_url")
roundID := firstGamePayloadString(source, "provider_round_id", "round_id")
payload := map[string]any{
"event_id": eventID,
"source_event_id": message.EventID,
"broadcast_type": broadcastdomain.TypeGameWin,
"scope": broadcastdomain.ScopeRegion,
"app_code": message.AppCode,
"region_id": regionID,
"visible_region_id": regionID,
"order_id": message.OrderID,
"platform_code": message.PlatformCode,
"game_id": gameID,
"provider_game_id": providerGameID,
"game_name": gameName,
"game_icon_url": gameIconURL,
"game_round_id": roundID,
"round_id": roundID,
"room_id": roomID,
"room_locked": room.Locked,
"room_name": room.Title,
"user_id": message.UserID,
"sender_user_id": message.UserID,
"account": profile.Account,
"actual_account": profile.Account,
"user_name": profile.Nickname,
"user_nickname": profile.Nickname,
"user_avatar_url": profile.Avatar,
"coins": message.CoinAmount,
"win_amount": message.CoinAmount,
"award_amount": message.CoinAmount,
"currency_diff": message.CoinAmount,
"wallet_transaction_id": firstGamePayloadString(source, "wallet_transaction_id"),
"occurred_at_ms": message.OccurredAtMS,
"sent_at_ms": sentAtMS,
"user": map[string]any{
"user_id": profile.UserID,
"display_user_id": profile.Account,
"nickname": profile.Nickname,
"avatar": profile.Avatar,
},
}
if roomID != "" {
payload["jump_type"] = "room_window"
payload["jump_payload"] = map[string]any{
"room_id": roomID,
"room_locked": room.Locked,
"window": "game_window",
"game_id": gameID,
}
payload["action"] = map[string]any{
"type": "room_window",
"room_id": roomID,
"room_locked": room.Locked,
"window": "game_window",
"game_id": gameID,
}
}
encoded, err := json.Marshal(payload)
return string(encoded), err
}
func decodeGameWinSourcePayload(raw string) (map[string]any, error) {
if strings.TrimSpace(raw) == "" {
return map[string]any{}, nil
}
var payload map[string]any
if err := json.Unmarshal([]byte(raw), &payload); err != nil {
return nil, fmt.Errorf("decode game order payload: %w", err)
}
if payload == nil {
payload = map[string]any{}
}
return payload, nil
}
func firstGamePayloadString(payload map[string]any, keys ...string) string {
for _, key := range keys {
value, exists := payload[key]
if !exists || value == nil {
continue
}
if normalized := strings.TrimSpace(fmt.Sprint(value)); normalized != "" && normalized != "<nil>" {
return normalized
}
}
return ""
}
func firstGamePayloadInt64(payload map[string]any, keys ...string) int64 {
for _, key := range keys {
value, exists := payload[key]
if !exists {
continue
}
switch typed := value.(type) {
case float64:
return int64(typed)
case float32:
return int64(typed)
case int64:
return typed
case int:
return int64(typed)
case json.Number:
if parsed, err := typed.Int64(); err == nil {
return parsed
}
case string:
if parsed, err := strconv.ParseInt(strings.TrimSpace(typed), 10, 64); err == nil {
return parsed
}
}
}
return 0
}

View File

@ -154,6 +154,7 @@ func luckyGiftBigWinPayload(envelope *luckygifteventsv1.EventEnvelope, event *lu
"command_id": event.GetCommandId(),
"pool_id": event.GetPoolId(),
"room_id": event.GetRoomId(),
"room_locked": event.GetRoomLocked(),
"gift_id": event.GetGiftId(),
"gift_count": event.GetGiftCount(),
"user_id": event.GetSenderUserId(),

View File

@ -10,6 +10,7 @@ import (
"log/slog"
"sort"
"strings"
"sync"
"sync/atomic"
"time"
@ -27,6 +28,8 @@ const (
defaultBroadcastBatchSize = 100
defaultBroadcastLockTTL = 30 * time.Second
defaultBroadcastMaxRetry = 8
defaultGameWinMinCoins = int64(100000)
defaultGameWinCacheTTL = 5 * time.Second
)
// Repository 是播报消息的持久化 outbox 边界。
@ -57,6 +60,27 @@ type SenderProfileSource interface {
GetSenderProfile(ctx context.Context, userID int64) (SenderProfile, error)
}
// RoomSnapshotSource 只读取 game_win 点击入口需要的房间展示快照。
// 锁房状态仍由 Room Cell 拥有activity-service 不复制密码哈希或房间核心状态。
type RoomSnapshotSource interface {
GetRoomBroadcastSnapshot(ctx context.Context, roomID string) (RoomBroadcastSnapshot, error)
}
type RoomBroadcastSnapshot struct {
RoomID string
Title string
Locked bool
VisibleRegionID int64
Status string
}
// GameWinConfigRepository 是游戏中奖飘屏策略的独立持久化边界。
// 它不并入通用 outbox Repository避免测试或离线投递器被迫实现无关配置接口。
type GameWinConfigRepository interface {
GetGameWinBroadcastConfig(ctx context.Context) (broadcastdomain.GameWinConfig, bool, error)
UpsertGameWinBroadcastConfig(ctx context.Context, config broadcastdomain.GameWinConfig) (broadcastdomain.GameWinConfig, error)
}
// SenderProfile 是播报 IM payload 的发送者展示字段。
// activity-service 不持久化该资料,只在消费事实时组装一次播报消息。
type SenderProfile struct {
@ -74,8 +98,11 @@ type SenderProfile struct {
type Config struct {
NodeID string
// AppCodes 是共享腾讯 IM 应用内需要维护的业务租户;群名和 outbox 仍按 app_code 隔离。
AppCodes []string
SuperGiftMinValue int64
AppCodes []string
SuperGiftMinValue int64
// GameWinMinCoins 仅作为未创建 App 配置行时的启用默认值;后台保存后以 MySQL 配置为准。
GameWinMinCoins int64
GameWinConfigCacheTTL time.Duration
WorkerBatchSize int
WorkerLockTTL time.Duration
WorkerMaxRetry int
@ -105,6 +132,10 @@ type Service struct {
publisher Publisher
regionSource RegionSource
senderProfileSource SenderProfileSource
roomSnapshotSource RoomSnapshotSource
gameWinConfigRepo GameWinConfigRepository
gameWinConfigMu sync.RWMutex
gameWinConfigCache map[string]cachedGameWinConfig
now func() time.Time
// outboxClaimCursor 轮转跨 App claim 起点,避免默认 lalu 的持续积压饿死其他租户播报。
outboxClaimCursor atomic.Uint64
@ -114,7 +145,18 @@ type Service struct {
// publisher 可以为 nil此时仍允许消费事件并写 outbox但真正投递会 fail-closed本地环境默认不误发腾讯云消息。
func New(cfg Config, repository Repository, publisher Publisher, regionSource RegionSource) *Service {
cfg = normalizeConfig(cfg)
return &Service{cfg: cfg, repository: repository, publisher: publisher, regionSource: regionSource, now: time.Now}
service := &Service{
cfg: cfg,
repository: repository,
publisher: publisher,
regionSource: regionSource,
gameWinConfigCache: make(map[string]cachedGameWinConfig),
now: time.Now,
}
if configRepo, ok := repository.(GameWinConfigRepository); ok {
service.gameWinConfigRepo = configRepo
}
return service
}
// SetClock 注入确定性时间,测试中用它验证 payload 和 outbox 的 Unix ms 字段。
@ -132,6 +174,13 @@ func (s *Service) SetSenderProfileSource(source SenderProfileSource) {
}
}
// SetRoomSnapshotSource 注入 Room Cell 的只读展示快照边界;有 room_id 的 game_win 在缺少该依赖时拒绝生成错误锁房字段。
func (s *Service) SetRoomSnapshotSource(source RoomSnapshotSource) {
if s != nil {
s.roomSnapshotSource = source
}
}
// EnsureBroadcastGroups 幂等创建全局播报群和所有 active 区域播报群。
// 这个动作放在启动/定时 reconciler不放在 /im/usersig 路径,避免用户登录被外部腾讯 REST 拖慢。
// 单个群创建失败不中断其余群的补偿:全局群配额问题不应让所有区域群一起缺失,错误聚合后返回。
@ -685,6 +734,12 @@ func normalizeConfig(cfg Config) Config {
if cfg.SuperGiftMinValue <= 0 {
cfg.SuperGiftMinValue = 100000
}
if cfg.GameWinMinCoins <= 0 {
cfg.GameWinMinCoins = defaultGameWinMinCoins
}
if cfg.GameWinConfigCacheTTL <= 0 {
cfg.GameWinConfigCacheTTL = defaultGameWinCacheTTL
}
if cfg.WorkerBatchSize <= 0 {
cfg.WorkerBatchSize = defaultBroadcastBatchSize
}
@ -802,6 +857,7 @@ func superGiftPayload(envelope *roomeventsv1.EventEnvelope, gift *roomeventsv1.R
"app_code": envelope.GetAppCode(),
"region_id": gift.GetVisibleRegionId(),
"room_id": envelope.GetRoomId(),
"room_locked": gift.GetRoomLocked(),
"sender_user_id": gift.GetSenderUserId(),
"sender_display_user_id": sender.Account,
"sender_nickname": sender.Nickname,

View File

@ -11,6 +11,7 @@ import (
luckygifteventsv1 "hyapp.local/api/proto/events/luckygift/v1"
roomeventsv1 "hyapp.local/api/proto/events/room/v1"
"hyapp/pkg/appcode"
"hyapp/pkg/gamemq"
"hyapp/pkg/imgroup"
"hyapp/pkg/luckygiftmq"
"hyapp/pkg/tencentim"
@ -94,6 +95,7 @@ func TestHandleRoomGiftSentCreatesRegionBroadcast(t *testing.T) {
GiftCount: 2,
GiftValue: 999,
VisibleRegionId: 1001,
RoomLocked: true,
CommandId: "cmd-send-gift-1",
GiftName: "Rocket",
GiftIconUrl: "https://cdn.example/rocket.png",
@ -115,7 +117,7 @@ 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["gift_id"] != "rocket" || payload["scope"] != "region" || payload["gift_icon_url"] != "https://cdn.example/rocket.png" {
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" {
t.Fatalf("payload mismatch: %+v", payload)
}
sender, _ := payload["sender"].(map[string]any)
@ -153,6 +155,7 @@ func TestHandleLuckyGiftEventCreatesTenTimesRegionBroadcastIdempotently(t *testi
SenderDisplayUserId: "160042",
SenderPrettyDisplayUserId: "888042",
VisibleRegionId: 210,
RoomLocked: true,
CountryId: 15,
CoinSpent: 100,
RuleVersion: 7,
@ -186,7 +189,7 @@ 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["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["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 {
@ -210,6 +213,56 @@ func TestHandleLuckyGiftEventCreatesTenTimesRegionBroadcastIdempotently(t *testi
}
}
func TestHandleGameWinUsesAppThresholdAndLockedRoomSnapshot(t *testing.T) {
repository := newFakeRepository()
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"}})
ctx := appcode.WithContext(context.Background(), "fami")
config, err := service.UpdateGameWinBroadcastConfig(ctx, true, 500, 7)
if err != nil || config.MinWinCoins != 500 || config.UpdatedByAdminID != 7 {
t.Fatalf("update game win config failed: config=%+v err=%v", config, err)
}
message := gamemq.GameOutboxMessage{
AppCode: "fami", EventID: "GameOrderSettled:order-1", EventType: gamemq.EventTypeGameOrderSettled,
OrderID: "order-1", UserID: 42, PlatformCode: "provider", GameID: "slots", OpType: "credit",
CoinAmount: 500, OccurredAtMS: 1_799_999_999_000,
PayloadJSON: `{"room_id":"room-310","visible_region_id":310,"game_name":"Slots","game_icon_url":"https://cdn.example/slots.png","provider_round_id":"round-1","wallet_transaction_id":"wallet-1"}`,
}
result, err := service.HandleGameEvent(ctx, message)
if err != nil {
t.Fatalf("HandleGameEvent failed: %v", err)
}
if !result.BroadcastCreated || result.BroadcastEventID != "game_win:order-1" {
t.Fatalf("game win result mismatch: %+v", result)
}
record := repository.records[result.BroadcastEventID]
if record.BroadcastType != broadcastdomain.TypeGameWin || record.GroupID != "hy_fami_bc_r_v2_310" {
t.Fatalf("game win outbox mismatch: %+v", record)
}
var payload map[string]any
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" {
t.Fatalf("game win payload mismatch: %+v", payload)
}
jump, _ := payload["jump_payload"].(map[string]any)
if jump["room_locked"] != true || jump["window"] != "game_window" {
t.Fatalf("game win jump must carry lock and game window: %+v", jump)
}
message.OrderID = "order-below"
message.EventID = "GameOrderSettled:order-below"
message.CoinAmount = 499
below, err := service.HandleGameEvent(ctx, message)
if err != nil || below.Status != broadcastdomain.StatusSkipped || len(repository.records) != 1 {
t.Fatalf("below-threshold payout must be skipped: result=%+v err=%v records=%d", below, err, len(repository.records))
}
}
func TestHandleLuckyGiftEventSkipsNonBigWinFacts(t *testing.T) {
tests := []struct {
name string
@ -779,11 +832,26 @@ func findCustomMessageEvent(messages []tencentim.CustomGroupMessage, eventID str
}
type fakeRepository struct {
records map[string]broadcastdomain.OutboxRecord
records map[string]broadcastdomain.OutboxRecord
gameWinConfigs map[string]broadcastdomain.GameWinConfig
}
func newFakeRepository() *fakeRepository {
return &fakeRepository{records: map[string]broadcastdomain.OutboxRecord{}}
return &fakeRepository{records: map[string]broadcastdomain.OutboxRecord{}, gameWinConfigs: map[string]broadcastdomain.GameWinConfig{}}
}
func (r *fakeRepository) GetGameWinBroadcastConfig(ctx context.Context) (broadcastdomain.GameWinConfig, bool, error) {
config, exists := r.gameWinConfigs[appcode.FromContext(ctx)]
return config, exists, nil
}
func (r *fakeRepository) UpsertGameWinBroadcastConfig(ctx context.Context, config broadcastdomain.GameWinConfig) (broadcastdomain.GameWinConfig, error) {
app := appcode.FromContext(ctx)
if existing, exists := r.gameWinConfigs[app]; exists {
config.CreatedAtMS = existing.CreatedAtMS
}
r.gameWinConfigs[app] = config
return config, nil
}
func (r *fakeRepository) SaveBroadcastOutbox(_ context.Context, record broadcastdomain.OutboxRecord) (bool, broadcastdomain.OutboxRecord, error) {
@ -881,6 +949,15 @@ type fakeSenderProfileSource struct {
err error
}
type fakeRoomBroadcastSource struct {
snapshot RoomBroadcastSnapshot
err error
}
func (s fakeRoomBroadcastSource) GetRoomBroadcastSnapshot(context.Context, string) (RoomBroadcastSnapshot, error) {
return s.snapshot, s.err
}
func (s fakeSenderProfileSource) GetSenderProfile(_ context.Context, userID int64) (SenderProfile, error) {
if s.profiles != nil {
if profile, ok := s.profiles[userID]; ok {

View File

@ -0,0 +1,81 @@
package mysql
import (
"context"
"database/sql"
"errors"
"hyapp/pkg/appcode"
"hyapp/pkg/xerr"
broadcastdomain "hyapp/services/activity-service/internal/domain/broadcast"
)
// ensureGameWinBroadcastConfigTable 只创建按 app_code 主键读取的小配置表。
// 本地自动迁移需要它保持开箱可用;线上仍由版本化 migration 先执行,避免服务启动临时持有 DDL 锁。
func (r *Repository) ensureGameWinBroadcastConfigTable(ctx context.Context) error {
_, err := r.db.ExecContext(ctx, `
CREATE TABLE IF NOT EXISTS game_win_broadcast_configs (
app_code VARCHAR(32) NOT NULL COMMENT '应用编码用于多租户隔离',
enabled TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否发送游戏中奖飘屏',
min_win_coins BIGINT NOT NULL COMMENT '单次返奖达到该金币数才发送飘屏',
updated_by_admin_id BIGINT NOT NULL DEFAULT 0 COMMENT '最后更新管理员 ID',
created_at_ms BIGINT NOT NULL COMMENT '创建时间UTC epoch ms',
updated_at_ms BIGINT NOT NULL COMMENT '更新时间UTC epoch ms',
PRIMARY KEY (app_code)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='游戏中奖飘屏配置'`)
return err
}
// GetGameWinBroadcastConfig 通过 app_code 主键点查当前策略;未配置由 service 返回安全默认值。
func (r *Repository) GetGameWinBroadcastConfig(ctx context.Context) (broadcastdomain.GameWinConfig, bool, error) {
if r == nil || r.db == nil {
return broadcastdomain.GameWinConfig{}, false, xerr.New(xerr.Unavailable, "mysql repository is not configured")
}
var config broadcastdomain.GameWinConfig
err := r.db.QueryRowContext(ctx, `
SELECT enabled, min_win_coins, updated_by_admin_id, created_at_ms, updated_at_ms
FROM game_win_broadcast_configs
WHERE app_code = ?`, appcode.FromContext(ctx)).Scan(
&config.Enabled,
&config.MinWinCoins,
&config.UpdatedByAdminID,
&config.CreatedAtMS,
&config.UpdatedAtMS,
)
if errors.Is(err, sql.ErrNoRows) {
return broadcastdomain.GameWinConfig{}, false, nil
}
if err != nil {
return broadcastdomain.GameWinConfig{}, false, err
}
return config, true, nil
}
// UpsertGameWinBroadcastConfig 原子替换当前 App 策略;创建时间保持不变,更新审计字段随每次保存推进。
func (r *Repository) UpsertGameWinBroadcastConfig(ctx context.Context, config broadcastdomain.GameWinConfig) (broadcastdomain.GameWinConfig, error) {
if r == nil || r.db == nil {
return broadcastdomain.GameWinConfig{}, xerr.New(xerr.Unavailable, "mysql repository is not configured")
}
_, err := r.db.ExecContext(ctx, `
INSERT INTO game_win_broadcast_configs (
app_code, enabled, min_win_coins, updated_by_admin_id, created_at_ms, updated_at_ms
) VALUES (?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
enabled = VALUES(enabled),
min_win_coins = VALUES(min_win_coins),
updated_by_admin_id = VALUES(updated_by_admin_id),
updated_at_ms = VALUES(updated_at_ms)`,
appcode.FromContext(ctx), config.Enabled, config.MinWinCoins, config.UpdatedByAdminID, config.CreatedAtMS, config.UpdatedAtMS,
)
if err != nil {
return broadcastdomain.GameWinConfig{}, err
}
stored, exists, err := r.GetGameWinBroadcastConfig(ctx)
if err != nil {
return broadcastdomain.GameWinConfig{}, err
}
if !exists {
return broadcastdomain.GameWinConfig{}, xerr.New(xerr.Unavailable, "game win broadcast config was not persisted")
}
return stored, nil
}

View File

@ -77,6 +77,9 @@ func (r *Repository) Migrate(ctx context.Context) error {
if err := r.ensureBroadcastOutboxIndexes(ctx); err != nil {
return err
}
if err := r.ensureGameWinBroadcastConfigTable(ctx); err != nil {
return err
}
if err := r.ensureWeeklyStarTables(ctx); err != nil {
return err
}

View File

@ -7,6 +7,7 @@ import (
activityv1 "hyapp.local/api/proto/activity/v1"
"hyapp/pkg/appcode"
"hyapp/pkg/xerr"
broadcastdomain "hyapp/services/activity-service/internal/domain/broadcast"
activitytemplateservice "hyapp/services/activity-service/internal/service/activitytemplate"
agencyopeningservice "hyapp/services/activity-service/internal/service/agencyopening"
broadcastservice "hyapp/services/activity-service/internal/service/broadcast"
@ -21,6 +22,7 @@ import (
type BroadcastServer struct {
activityv1.UnimplementedBroadcastServiceServer
activityv1.UnimplementedRoomEventConsumerServiceServer
activityv1.UnimplementedAdminBroadcastServiceServer
svc *broadcastservice.Service
growth *growthservice.Service
@ -31,6 +33,34 @@ type BroadcastServer struct {
activityTemplate *activitytemplateservice.Service
}
func (s *BroadcastServer) GetGameWinBroadcastConfig(ctx context.Context, req *activityv1.GetGameWinBroadcastConfigRequest) (*activityv1.GetGameWinBroadcastConfigResponse, error) {
ctx = appcode.WithContext(ctx, req.GetMeta().GetAppCode())
config, err := s.svc.GetGameWinBroadcastConfig(ctx)
if err != nil {
return nil, xerr.ToGRPCError(err)
}
return &activityv1.GetGameWinBroadcastConfigResponse{Config: gameWinBroadcastConfigProto(config)}, nil
}
func (s *BroadcastServer) UpdateGameWinBroadcastConfig(ctx context.Context, req *activityv1.UpdateGameWinBroadcastConfigRequest) (*activityv1.UpdateGameWinBroadcastConfigResponse, error) {
ctx = appcode.WithContext(ctx, req.GetMeta().GetAppCode())
stored, err := s.svc.UpdateGameWinBroadcastConfig(ctx, req.GetEnabled(), req.GetMinWinCoins(), req.GetOperatorAdminId())
if err != nil {
return nil, xerr.ToGRPCError(err)
}
return &activityv1.UpdateGameWinBroadcastConfigResponse{Config: gameWinBroadcastConfigProto(stored)}, nil
}
func gameWinBroadcastConfigProto(config broadcastdomain.GameWinConfig) *activityv1.GameWinBroadcastConfig {
return &activityv1.GameWinBroadcastConfig{
Enabled: config.Enabled,
MinWinCoins: config.MinWinCoins,
UpdatedByAdminId: config.UpdatedByAdminID,
CreatedAtMs: config.CreatedAtMS,
UpdatedAtMs: config.UpdatedAtMS,
}
}
// NewBroadcastServer 创建播报和 room event 消费共用的 gRPC adapter。
// room-service 的 outbox 事件只投递一次,但 activity-service 内部有多条独立投影:
// 播报负责 IM 展示growth 负责等级进度weekly-star 负责指定礼物榜单roomReward 负责房间流水。

View File

@ -1153,6 +1153,7 @@ func insertGameLevelEventOutbox(ctx context.Context, tx *sql.Tx, order gamedomai
func insertGameStatisticsOutbox(ctx context.Context, tx *sql.Tx, order gamedomain.GameOrder, walletTransactionID string, balanceAfter int64, nowMs int64) error {
eventID := "GameOrderSettled:" + order.OrderID
// 国家必须来自订单快照region_id 只能表达区域分桶,不能替代国家维度。
// 游戏名称和图标用 game_catalog 复合主键点查固化到 owner outbox消费端无需反查游戏库也不会把结算写放大成目录扫描。
_, err := tx.ExecContext(ctx, `
INSERT IGNORE INTO game_outbox (
app_code, event_id, event_type, order_id, user_id, platform_code, game_id, op_type,
@ -1161,6 +1162,8 @@ func insertGameStatisticsOutbox(ctx context.Context, tx *sql.Tx, order gamedomai
'order_id', ?,
'platform_code', ?,
'game_id', ?,
'game_name', COALESCE((SELECT c.game_name FROM game_catalog c WHERE c.app_code = ? AND c.game_id = ?), ''),
'game_icon_url', COALESCE((SELECT COALESCE(NULLIF(c.icon_url, ''), c.cover_url) FROM game_catalog c WHERE c.app_code = ? AND c.game_id = ?), ''),
'provider_game_id', ?,
'provider_order_id', ?,
'provider_round_id', ?,
@ -1175,7 +1178,9 @@ func insertGameStatisticsOutbox(ctx context.Context, tx *sql.Tx, order gamedomai
'wallet_balance_after', ?
), 'pending', ?, ?)`,
order.AppCode, eventID, order.OrderID, order.UserID, order.PlatformCode, order.GameID, order.OpType, order.CoinAmount,
order.OrderID, order.PlatformCode, order.GameID, order.ProviderGameID, order.ProviderOrderID, order.ProviderRoundID,
order.OrderID, order.PlatformCode, order.GameID,
order.AppCode, order.GameID, order.AppCode, order.GameID,
order.ProviderGameID, order.ProviderOrderID, order.ProviderRoundID,
order.UserID, order.RoomID, order.RegionID, order.RegionID, order.CountryID, order.OpType, order.CoinAmount, walletTransactionID, balanceAfter,
nowMs, nowMs,
)

View File

@ -207,10 +207,12 @@ type DrawCommand struct {
// Zero is tolerated for an old recovered command but makes the 48-hour jackpot maturity gate fail closed.
UserRegisteredAtMS int64
VisibleRegionID int64
CountryID int64
Recharge7DCoins int64
Recharge30DCoins int64
LastRechargedAtMS int64
// RoomLocked 是原始送礼时的房间展示快照,只能随 owner fact 透传,不能参与中奖概率和账务语义。
RoomLocked bool
CountryID int64
Recharge7DCoins int64
Recharge30DCoins int64
LastRechargedAtMS int64
// HostPeriodDiamondAdded 是 wallet 按后台礼物比例配置实际入账的主播钻石,资金拆分必须以该 owner 事实为准。
HostPeriodDiamondAdded int64
// Sender* 是扣费时刻的公开展示快照owner outbox 必须携带快照,避免下游为一条飘屏同步反查 user-service。

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.GetRewardGrantedAtMs() != 1783940398320 {
if fact.GetRewardStatus() != domain.StatusGranted || fact.GetWalletTransactionId() != "wallet-tx-1" || fact.GetMultiplierPpm() != luckygiftmq.LuckyGiftDisplayMinMultiplierPPM || fact.GetSenderName() != "Lucky Sender" || !fact.GetRoomLocked() || fact.GetRewardGrantedAtMs() != 1783940398320 {
t.Fatalf("unexpected owner fact: %+v", &fact)
}
}
@ -164,6 +164,7 @@ func testLuckyGiftDrawnPayload() luckyGiftDrawnPayload {
SenderAvatar: "https://cdn.example/sender.png",
SenderDisplayUserID: "123456",
VisibleRegionID: 1,
RoomLocked: true,
CountryID: 86,
CoinSpent: 100,
MultiplierPPM: luckygiftmq.LuckyGiftDisplayMinMultiplierPPM,

View File

@ -525,6 +525,7 @@ func (s *Service) creditLuckyGiftRewardFastPath(ctx context.Context, cmd domain.
SenderDisplayUserID: cmd.SenderDisplayUserID,
SenderPrettyDisplayUserID: cmd.SenderPrettyDisplayUserID,
VisibleRegionID: cmd.VisibleRegionID,
RoomLocked: cmd.RoomLocked,
CountryID: cmd.CountryID,
CoinSpent: cmd.CoinSpent,
RuleVersion: result.RuleVersion,
@ -598,6 +599,7 @@ func (s *Service) publishLuckyGiftDrawnFact(ctx context.Context, payload luckyGi
SenderDisplayUserId: payload.SenderDisplayUserID,
SenderPrettyDisplayUserId: payload.SenderPrettyDisplayUserID,
VisibleRegionId: payload.VisibleRegionID,
RoomLocked: payload.RoomLocked,
CountryId: payload.CountryID,
CoinSpent: payload.CoinSpent,
RuleVersion: payload.RuleVersion,
@ -1083,6 +1085,7 @@ type luckyGiftDrawnPayload struct {
SenderDisplayUserID string `json:"sender_display_user_id,omitempty"`
SenderPrettyDisplayUserID string `json:"sender_pretty_display_user_id,omitempty"`
VisibleRegionID int64 `json:"visible_region_id"`
RoomLocked bool `json:"room_locked"`
CountryID int64 `json:"country_id"`
CoinSpent int64 `json:"coin_spent"`
RuleVersion int64 `json:"rule_version"`

View File

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

View File

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

View File

@ -359,6 +359,7 @@ func roomEventFromEnvelope(envelope *roomeventsv1.EventEnvelope) (tencentim.Room
}
base.Attributes = map[string]string{
"gift_id": body.GetGiftId(),
"room_locked": fmt.Sprintf("%t", body.GetRoomLocked()),
"gift_count": fmt.Sprintf("%d", body.GetGiftCount()),
"coin_spent": fmt.Sprintf("%d", body.GetCoinSpent()),
"billing_receipt_id": body.GetBillingReceiptId(),
@ -472,6 +473,7 @@ func roomEventFromEnvelope(envelope *roomeventsv1.EventEnvelope) (tencentim.Room
"gift_count": fmt.Sprintf("%d", body.GetGiftCount()),
"pool_id": body.GetPoolId(),
"visible_region_id": fmt.Sprintf("%d", body.GetVisibleRegionId()),
"room_locked": fmt.Sprintf("%t", body.GetRoomLocked()),
"country_id": fmt.Sprintf("%d", body.GetCountryId()),
"coin_spent": fmt.Sprintf("%d", body.GetCoinSpent()),
"rule_version": fmt.Sprintf("%d", body.GetRuleVersion()),

View File

@ -353,6 +353,7 @@ func TestRoomLuckyGiftDrawnPublishesGrantedOwnerFactAsLuckyGiftIMEvent(t *testin
SenderDisplayUserId: "123456",
SenderPrettyDisplayUserId: "VIP123456",
VisibleRegionId: 1,
RoomLocked: true,
CountryId: 86,
CoinSpent: 100,
RuleVersion: 7,
@ -384,6 +385,7 @@ func TestRoomLuckyGiftDrawnPublishesGrantedOwnerFactAsLuckyGiftIMEvent(t *testin
event.Attributes["sender_pretty_display_user_id"] != "VIP123456" ||
event.Attributes["multiplier_ppm"] != "50000000" ||
event.Attributes["reward_status"] != "granted" ||
event.Attributes["room_locked"] != "true" ||
event.Attributes["created_at_ms"] != "1783940398274" ||
event.Attributes["wallet_transaction_id"] != "wallet-lucky-1" {
t.Fatalf("owner lucky gift IM attributes mismatch: %+v", event.Attributes)

View File

@ -493,6 +493,8 @@ type SendGift struct {
Source string `json:"source,omitempty"`
// DisplayMode 只控制客户端展示事件形态batch 表示保留逐目标事实,但房间 IM 合并为单条多人展示消息。
DisplayMode string `json:"display_mode,omitempty"`
// RoomLocked 固化首次执行送礼命令时的 Room Cell 锁状态。指针用于区分历史 saga 未保存该字段与明确的 false。
RoomLocked *bool `json:"room_locked,omitempty"`
// ComboSessionID/BatchSeq 描述 Flutter 微批顺序;它们进入 command_id 载荷冲突校验,
// 但不能替代 Base.CommandID 作为钱包、抽奖和房间提交的唯一幂等键。
ComboSessionID string `json:"combo_session_id,omitempty"`
@ -730,6 +732,8 @@ func IdempotencyPayload(payload []byte) ([]byte, error) {
// 注册时间与国家/区域同属 gateway 从 user owner 解析的可信快照。首次请求会完整持久化,
// 但它不是客户端送礼载荷;同一 command_id 重试不能因为用户快照修复或迁移而产生冲突。
delete(values, "sender_registered_at_ms")
// room_locked 是 Room Cell 在首次执行时补入的展示快照,不是客户端请求字段;重试不能因后续改密而触发幂等冲突。
delete(values, "room_locked")
// SendGift 的以下字段由 wallet-service 结算后写入 command log不属于客户端请求幂等语义。
delete(values, "billing_receipt_id")
delete(values, "coin_spent")

View File

@ -312,5 +312,6 @@ func adminRoomListItemProto(item AdminRoomListEntry) *roomv1.AdminRoomListItem {
SortScore: item.SortScore,
CreatedAtMs: item.CreatedAtMS,
UpdatedAtMs: item.UpdatedAtMS,
Locked: item.Locked,
}
}

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

View File

@ -58,7 +58,7 @@ func TestBuildRoomGiftSentRecordsPreservesDurableFactFields(t *testing.T) {
GiftEffectTypes: []string{"room_banner", "global_broadcast"},
}
records, err := buildRoomGiftSentRecords("room-gift-fact", 7, now, cmd, RoomMeta{VisibleRegionID: 8008}, []giftTargetBilling{{
records, err := buildRoomGiftSentRecords("room-gift-fact", 7, now, cmd, RoomMeta{VisibleRegionID: 8008}, true, []giftTargetBilling{{
TargetUserID: 202,
CommandID: "cmd-gift-fact:202",
Billing: billing,
@ -84,7 +84,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" {
if event.GetVisibleRegionId() != 8008 || event.GetCountryId() != 86 || event.GetRegionId() != 9001 || event.GetCommandId() != "cmd-gift-fact:202" || !event.GetRoomLocked() {
t.Fatalf("routing/stat fields mismatch: %+v", &event)
}
if event.GetGiftTypeCode() != "lucky" || event.GetCpRelationType() != "couple" || event.GetGiftName() != "Rose" {
@ -125,7 +125,7 @@ func TestBuildRoomGiftSentRecordsKeepsBatchDisplayFactsSplitPerTarget(t *testing
HeatValue: 100,
}
records, err := buildRoomGiftSentRecords("room-gift-batch-facts", 9, now, cmd, RoomMeta{}, []giftTargetBilling{
records, err := buildRoomGiftSentRecords("room-gift-batch-facts", 9, now, cmd, RoomMeta{}, false, []giftTargetBilling{
{TargetUserID: 202, CommandID: "cmd-gift-batch-facts:202", Billing: billing},
{TargetUserID: 203, CommandID: "cmd-gift-batch-facts:203", Billing: billing},
}, nil)

View File

@ -28,7 +28,9 @@ type giftFlow struct {
cmd command.SendGift
roomMeta RoomMeta
roomMeta RoomMeta
// roomLocked 在进入钱包/幸运礼物副作用前从同一个 Room Cell 快照固化,保证后续所有飘屏描述同一时刻的锁房状态。
roomLocked bool
rocketConfig RoomRocketConfig
billing *walletv1.DebitGiftResponse
// targetBillings 保存 wallet-service 按目标结算后的账务事实;后续事件、麦位热度和 batch 展示都必须从这里读取,不能重新按客户端输入推导价格。
@ -110,6 +112,15 @@ func newGiftFlow(s *Service, ctx context.Context, req *roomv1.SendGiftRequest, o
}
func (f *giftFlow) validateBeforeDebit(current *state.RoomState) error {
// 新命令在 Room Cell 串行区间固化锁状态;恢复优先复用首次快照。历史 saga 没有该字段时才用当前 Cell 状态兼容补齐。
if f.recovering && f.cmd.RoomLocked != nil {
f.roomLocked = *f.cmd.RoomLocked
} else {
f.roomLocked = strings.TrimSpace(current.RoomPasswordHash) != ""
locked := f.roomLocked
f.cmd.RoomLocked = &locked
f.settledCommand.RoomLocked = &locked
}
if !f.recovering {
if _, exists := current.OnlineUsers[f.cmd.ActorUserID()]; !exists {
// 送礼者必须在房间业务 presence 内;没有进入 Room Cell 的用户不能触发钱包扣费。
@ -298,7 +309,7 @@ func (f *giftFlow) debitAndSettle(now time.Time) error {
f.luckyEnabled = f.s.shouldDrawLuckyGift(f.cmd.PoolID, f.billing.GetGiftTypeCode())
}
if !f.cmd.SyntheticLuckyGift && f.luckyEnabled {
f.luckyGifts, err = f.s.executeLuckyGiftDraws(f.ctx, f.req.GetMeta(), f.cmd, f.roomMeta, f.targetBillings)
f.luckyGifts, err = f.s.executeLuckyGiftDraws(f.ctx, f.req.GetMeta(), f.cmd, f.roomMeta, f.roomLocked, f.targetBillings)
if err != nil {
// 抽奖失败时整笔送礼失败并依赖 wallet 幂等重试;不能落普通礼物后丢失幸运礼物抽奖事实。
return err
@ -356,7 +367,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.targetBillings, f.targetCurrentGiftValues)
giftEvents, err := buildRoomGiftSentRecords(current.RoomID, current.Version, now, f.cmd, f.roomMeta, f.roomLocked, f.targetBillings, f.targetCurrentGiftValues)
if err != nil {
return mutationResult{}, nil, err
}
@ -511,6 +522,7 @@ func (f *giftFlow) toMutationResult(now time.Time, current *state.RoomState) mut
RoomVersion: current.Version,
Attributes: map[string]string{
"gift_id": f.cmd.GiftID,
"room_locked": fmt.Sprintf("%t", f.roomLocked),
"pool_id": f.cmd.PoolID,
"gift_count": fmt.Sprintf("%d", f.cmd.GiftCount),
"billing_receipt_id": f.billing.GetBillingReceiptId(),

View File

@ -22,27 +22,27 @@ const (
luckyGiftStrategyDynamicV3 = "dynamic_v3"
)
func (s *Service) executeLuckyGiftDraws(ctx context.Context, meta *roomv1.RequestMeta, cmd command.SendGift, roomMeta RoomMeta, targetBillings []giftTargetBilling) ([]*roomv1.LuckyGiftDrawResult, error) {
func (s *Service) executeLuckyGiftDraws(ctx context.Context, meta *roomv1.RequestMeta, cmd command.SendGift, roomMeta RoomMeta, roomLocked bool, targetBillings []giftTargetBilling) ([]*roomv1.LuckyGiftDrawResult, error) {
if len(targetBillings) == 0 {
return nil, nil
}
if len(targetBillings) == 1 {
result, err := s.executeLuckyGiftDraw(ctx, meta, cmd, roomMeta, targetBillings[0])
result, err := s.executeLuckyGiftDraw(ctx, meta, cmd, roomMeta, roomLocked, targetBillings[0])
if err != nil {
return nil, err
}
return []*roomv1.LuckyGiftDrawResult{result}, nil
}
return s.executeLuckyGiftDrawBatch(ctx, meta, cmd, roomMeta, targetBillings)
return s.executeLuckyGiftDrawBatch(ctx, meta, cmd, roomMeta, roomLocked, targetBillings)
}
func (s *Service) executeLuckyGiftDraw(ctx context.Context, meta *roomv1.RequestMeta, cmd command.SendGift, roomMeta RoomMeta, targetBilling giftTargetBilling) (*roomv1.LuckyGiftDrawResult, error) {
func (s *Service) executeLuckyGiftDraw(ctx context.Context, meta *roomv1.RequestMeta, cmd command.SendGift, roomMeta RoomMeta, roomLocked bool, targetBilling giftTargetBilling) (*roomv1.LuckyGiftDrawResult, error) {
if targetBilling.Billing == nil {
return nil, xerr.New(xerr.Unavailable, "wallet target debit response is empty")
}
// 抽奖必须按每个收礼目标独立落事实command_id、target_user_id 和 coin_spent 都来自该目标的 wallet 子交易。
luckyMeta, err := luckyGiftMetaForTarget(ctx, meta, cmd, roomMeta, targetBilling)
luckyMeta, err := luckyGiftMetaForTarget(ctx, meta, cmd, roomMeta, roomLocked, targetBilling)
if err != nil {
return nil, err
}
@ -59,13 +59,13 @@ func (s *Service) executeLuckyGiftDraw(ctx context.Context, meta *roomv1.Request
return luckyGiftResultFromProto(drawResp.GetResult(), targetBilling.TargetUserID), nil
}
func (s *Service) executeLuckyGiftDrawBatch(ctx context.Context, meta *roomv1.RequestMeta, cmd command.SendGift, roomMeta RoomMeta, targetBillings []giftTargetBilling) ([]*roomv1.LuckyGiftDrawResult, error) {
func (s *Service) executeLuckyGiftDrawBatch(ctx context.Context, meta *roomv1.RequestMeta, cmd command.SendGift, roomMeta RoomMeta, roomLocked bool, targetBillings []giftTargetBilling) ([]*roomv1.LuckyGiftDrawResult, error) {
req := &luckygiftv1.BatchExecuteLuckyGiftDrawRequest{LuckyGifts: make([]*luckygiftv1.LuckyGiftMeta, 0, len(targetBillings))}
for _, targetBilling := range targetBillings {
if targetBilling.Billing == nil {
return nil, xerr.New(xerr.Unavailable, "wallet target debit response is empty")
}
luckyMeta, err := luckyGiftMetaForTarget(ctx, meta, cmd, roomMeta, targetBilling)
luckyMeta, err := luckyGiftMetaForTarget(ctx, meta, cmd, roomMeta, roomLocked, targetBilling)
if err != nil {
return nil, err
}
@ -89,7 +89,7 @@ func (s *Service) executeLuckyGiftDrawBatch(ctx context.Context, meta *roomv1.Re
return results, nil
}
func luckyGiftMetaForTarget(ctx context.Context, meta *roomv1.RequestMeta, cmd command.SendGift, roomMeta RoomMeta, targetBilling giftTargetBilling) (*luckygiftv1.LuckyGiftMeta, error) {
func luckyGiftMetaForTarget(ctx context.Context, meta *roomv1.RequestMeta, cmd command.SendGift, roomMeta RoomMeta, roomLocked bool, targetBilling giftTargetBilling) (*luckygiftv1.LuckyGiftMeta, error) {
paidAtMS := targetBilling.Billing.GetPaidAtMs()
if paidAtMS <= 0 && normalizeLuckyGiftStrategyVersion(cmd.LuckyGiftStrategyVersion) == luckyGiftStrategyDynamicV3 {
// dynamic_v3 所有时间窗口必须归属 wallet owner 的实际交易;缺字段通常表示 wallet 尚未滚动升级,
@ -114,7 +114,9 @@ func luckyGiftMetaForTarget(ctx context.Context, meta *roomv1.RequestMeta, cmd c
PaidAtMs: paidAtMS,
PoolId: cmd.PoolID,
VisibleRegionId: roomMeta.VisibleRegionID,
CountryId: cmd.SenderCountryID,
// 锁房状态与 presence 校验取自同一个串行 Room Cell 快照lucky owner 只持久化展示事实,不能跨库回查房间。
RoomLocked: roomLocked,
CountryId: cmd.SenderCountryID,
// 账号创建时间来自 gateway 的 user-service 快照并随首次命令持久化owner 只用它判断
// 支付时账号是否已满完整历史窗口。0 表示旧命令/缺失事实,补偿大奖资格必须 fail-close。
UserRegisteredAtMs: cmd.SenderRegisteredAtMS,

View File

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

View File

@ -83,6 +83,13 @@ func TestRoomPasswordControlsJoinAndListLockedFlag(t *testing.T) {
if len(listResp.GetRooms()) != 1 || !listResp.GetRooms()[0].GetLocked() {
t.Fatalf("list/search must return locked flag: %+v", listResp.GetRooms())
}
adminRoom, err := svc.AdminGetRoom(ctx, &roomv1.AdminGetRoomRequest{
Meta: &roomv1.RequestMeta{AppCode: appcode.Default},
RoomId: roomID,
})
if err != nil || !adminRoom.GetRoom().GetLocked() {
t.Fatalf("owner admin room snapshot must expose locked flag: room=%+v err=%v", adminRoom.GetRoom(), err)
}
if _, err := svc.JoinRoom(ctx, &roomv1.JoinRoomRequest{
Meta: roomservice.NewRequestMeta(roomID, viewerID),

View File

@ -57,6 +57,7 @@ func (s *Service) HandleLuckyGiftOutboxEvent(ctx context.Context, envelope *luck
SenderDisplayUserId: drawn.GetSenderDisplayUserId(),
SenderPrettyDisplayUserId: drawn.GetSenderPrettyDisplayUserId(),
VisibleRegionId: drawn.GetVisibleRegionId(),
RoomLocked: drawn.GetRoomLocked(),
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" {
if body.GetDrawId() != "lucky_draw_room_1" || body.GetSenderName() != "Sender" || body.GetMultiplierPpm() != luckygiftmq.LuckyGiftDisplayMinMultiplierPPM || body.GetRewardStatus() != "granted" || !body.GetRoomLocked() {
t.Fatalf("derived RoomLuckyGiftDrawn mismatch: %+v", &body)
}
}
@ -131,6 +131,7 @@ func luckyGiftDisplayEnvelope(t *testing.T, multiplierPPM int64) *luckygiftevent
SenderDisplayUserId: "123456",
SenderPrettyDisplayUserId: "VIP123456",
VisibleRegionId: 1,
RoomLocked: true,
CountryId: 86,
CoinSpent: 100,
RuleVersion: 3,

View File

@ -362,6 +362,7 @@ type AdminRoomListEntry struct {
CoverURL string
Mode string
Status string
Locked bool
CloseReason string
ClosedByAdminID uint64
ClosedByAdminName string

View File

@ -224,7 +224,7 @@ func (r *Repository) AdminGetRoom(ctx context.Context, roomID string, nowMS int6
func adminRoomSelectSQL(contributionExpr string) string {
return `
SELECT rle.room_id, rle.room_short_id, rle.visible_region_id, rle.owner_user_id,
rle.title, rle.cover_url, rle.mode, rle.status, rle.close_reason,
rle.title, rle.cover_url, rle.mode, rle.status, rle.locked, rle.close_reason,
rle.closed_by_admin_id, rle.closed_by_admin_name, rle.closed_at_ms,
` + contributionExpr + ` AS room_contribution, rle.online_count, rle.seat_count, rle.occupied_seat_count,
rle.sort_score, rle.created_at_ms, rle.updated_at_ms
@ -310,6 +310,7 @@ func scanAdminRoom(scanner interface{ Scan(dest ...any) error }) (roomservice.Ad
&item.CoverURL,
&item.Mode,
&item.Status,
&item.Locked,
&item.CloseReason,
&item.ClosedByAdminID,
&item.ClosedByAdminName,