From 61f4e31b6ad8bdf52617e529d11d0bb5f9edd5ab Mon Sep 17 00:00:00 2001 From: zhx Date: Wed, 20 May 2026 18:07:33 +0800 Subject: [PATCH] =?UTF-8?q?=E6=B8=B8=E6=88=8F=E9=83=A8=E7=BD=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/proto/activity/v1/activity.pb.go | 106 ++- api/proto/activity/v1/activity.proto | 9 + api/proto/activity/v1/activity_grpc.pb.go | 2 +- api/proto/events/room/v1/events.pb.go | 13 +- api/proto/events/room/v1/events.proto | 1 + api/proto/game/v1/game.pb.go | 75 ++- api/proto/game/v1/game.proto | 6 + api/proto/game/v1/game_grpc.pb.go | 2 +- api/proto/room/v1/room.pb.go | 13 +- api/proto/room/v1/room.proto | 1 + api/proto/user/v1/auth.pb.go | 2 +- api/proto/user/v1/auth_grpc.pb.go | 2 +- api/proto/user/v1/host.pb.go | 2 +- api/proto/user/v1/host_grpc.pb.go | 2 +- api/proto/user/v1/user.pb.go | 2 +- api/proto/user/v1/user_grpc.pb.go | 2 +- deploy/README.md | 3 +- docs/flutter对接/语音房宝箱Flutter对接.md | 611 ++++++++++++++++++ docs/幸运礼物线上落地文档.md | 39 +- .../internal/modules/gamemanagement/dto.go | 40 +- .../modules/gamemanagement/request.go | 28 +- .../internal/modules/luckygift/handler.go | 24 +- server/admin/internal/repository/seed.go | 3 +- .../019_remove_hidden_navigation_menus.sql | 6 +- .../mysql/initdb/001_activity_service.sql | 10 +- .../internal/domain/luckygift/lucky_gift.go | 88 +-- .../internal/service/luckygift/config.go | 141 ++-- .../internal/service/luckygift/config_test.go | 60 +- .../internal/service/luckygift/service.go | 17 +- .../service/luckygift/service_test.go | 19 +- .../storage/mysql/lucky_gift_repository.go | 251 +++++-- .../mysql/lucky_gift_repository_test.go | 24 +- .../transport/grpc/lucky_gift_server.go | 13 +- .../deploy/mysql/initdb/001_game_service.sql | 1 + .../game-service/internal/domain/game/game.go | 53 +- .../internal/service/game/leadercc_adapter.go | 283 ++++++++ .../internal/service/game/service.go | 96 ++- .../internal/service/game/service_test.go | 328 ++++++++++ .../internal/service/game/yomi_adapter.go | 532 +++++++++++++++ .../internal/storage/mysql/repository.go | 161 ++++- .../internal/transport/grpc/server.go | 86 ++- .../transport/http/roomapi/room_handler.go | 2 + .../internal/room/command/command.go | 2 + .../internal/room/service/gift.go | 3 + .../room/service/room_treasure_test.go | 3 +- 45 files changed, 2840 insertions(+), 327 deletions(-) create mode 100644 docs/flutter对接/语音房宝箱Flutter对接.md create mode 100644 services/game-service/internal/service/game/leadercc_adapter.go create mode 100644 services/game-service/internal/service/game/yomi_adapter.go diff --git a/api/proto/activity/v1/activity.pb.go b/api/proto/activity/v1/activity.pb.go index 751e29fd..20d7f5d1 100644 --- a/api/proto/activity/v1/activity.pb.go +++ b/api/proto/activity/v1/activity.pb.go @@ -1,7 +1,7 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: // protoc-gen-go v1.36.11 -// protoc v5.28.3 +// protoc (unknown) // source: proto/activity/v1/activity.proto package activityv1 @@ -8893,6 +8893,7 @@ type LuckyGiftMeta struct { GiftId string `protobuf:"bytes,7,opt,name=gift_id,json=giftId,proto3" json:"gift_id,omitempty"` CoinSpent int64 `protobuf:"varint,8,opt,name=coin_spent,json=coinSpent,proto3" json:"coin_spent,omitempty"` PaidAtMs int64 `protobuf:"varint,9,opt,name=paid_at_ms,json=paidAtMs,proto3" json:"paid_at_ms,omitempty"` + PoolId string `protobuf:"bytes,10,opt,name=pool_id,json=poolId,proto3" json:"pool_id,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -8990,6 +8991,13 @@ func (x *LuckyGiftMeta) GetPaidAtMs() int64 { return 0 } +func (x *LuckyGiftMeta) GetPoolId() string { + if x != nil { + return x.PoolId + } + return "" +} + type LuckyGiftTier struct { state protoimpl.MessageState `protogen:"open.v1"` Pool string `protobuf:"bytes,1,opt,name=pool,proto3" json:"pool,omitempty"` @@ -8998,6 +9006,7 @@ type LuckyGiftTier struct { Weight int64 `protobuf:"varint,4,opt,name=weight,proto3" json:"weight,omitempty"` HighWaterOnly bool `protobuf:"varint,5,opt,name=high_water_only,json=highWaterOnly,proto3" json:"high_water_only,omitempty"` Enabled bool `protobuf:"varint,6,opt,name=enabled,proto3" json:"enabled,omitempty"` + MultiplierPpm int64 `protobuf:"varint,7,opt,name=multiplier_ppm,json=multiplierPpm,proto3" json:"multiplier_ppm,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -9074,6 +9083,13 @@ func (x *LuckyGiftTier) GetEnabled() bool { return false } +func (x *LuckyGiftTier) GetMultiplierPpm() int64 { + if x != nil { + return x.MultiplierPpm + } + return 0 +} + type LuckyGiftConfig struct { state protoimpl.MessageState `protogen:"open.v1"` AppCode string `protobuf:"bytes,1,opt,name=app_code,json=appCode,proto3" json:"app_code,omitempty"` @@ -9114,6 +9130,8 @@ type LuckyGiftConfig struct { UpdatedByAdminId int64 `protobuf:"varint,36,opt,name=updated_by_admin_id,json=updatedByAdminId,proto3" json:"updated_by_admin_id,omitempty"` CreatedAtMs int64 `protobuf:"varint,37,opt,name=created_at_ms,json=createdAtMs,proto3" json:"created_at_ms,omitempty"` UpdatedAtMs int64 `protobuf:"varint,38,opt,name=updated_at_ms,json=updatedAtMs,proto3" json:"updated_at_ms,omitempty"` + PoolId string `protobuf:"bytes,39,opt,name=pool_id,json=poolId,proto3" json:"pool_id,omitempty"` + MultiplierPpms []int64 `protobuf:"varint,40,rep,packed,name=multiplier_ppms,json=multiplierPpms,proto3" json:"multiplier_ppms,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -9414,12 +9432,27 @@ func (x *LuckyGiftConfig) GetUpdatedAtMs() int64 { return 0 } +func (x *LuckyGiftConfig) GetPoolId() string { + if x != nil { + return x.PoolId + } + return "" +} + +func (x *LuckyGiftConfig) GetMultiplierPpms() []int64 { + if x != nil { + return x.MultiplierPpms + } + return nil +} + type CheckLuckyGiftRequest struct { state protoimpl.MessageState `protogen:"open.v1"` Meta *RequestMeta `protobuf:"bytes,1,opt,name=meta,proto3" json:"meta,omitempty"` UserId int64 `protobuf:"varint,2,opt,name=user_id,json=userId,proto3" json:"user_id,omitempty"` RoomId string `protobuf:"bytes,3,opt,name=room_id,json=roomId,proto3" json:"room_id,omitempty"` GiftId string `protobuf:"bytes,4,opt,name=gift_id,json=giftId,proto3" json:"gift_id,omitempty"` + PoolId string `protobuf:"bytes,5,opt,name=pool_id,json=poolId,proto3" json:"pool_id,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -9482,6 +9515,13 @@ func (x *CheckLuckyGiftRequest) GetGiftId() string { return "" } +func (x *CheckLuckyGiftRequest) GetPoolId() string { + if x != nil { + return x.PoolId + } + return "" +} + type CheckLuckyGiftResponse struct { state protoimpl.MessageState `protogen:"open.v1"` Enabled bool `protobuf:"varint,1,opt,name=enabled,proto3" json:"enabled,omitempty"` @@ -9491,6 +9531,7 @@ type CheckLuckyGiftResponse struct { RuleVersion int64 `protobuf:"varint,5,opt,name=rule_version,json=ruleVersion,proto3" json:"rule_version,omitempty"` TargetRtpPpm int64 `protobuf:"varint,6,opt,name=target_rtp_ppm,json=targetRtpPpm,proto3" json:"target_rtp_ppm,omitempty"` ExperiencePool string `protobuf:"bytes,7,opt,name=experience_pool,json=experiencePool,proto3" json:"experience_pool,omitempty"` + PoolId string `protobuf:"bytes,8,opt,name=pool_id,json=poolId,proto3" json:"pool_id,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -9574,6 +9615,13 @@ func (x *CheckLuckyGiftResponse) GetExperiencePool() string { return "" } +func (x *CheckLuckyGiftResponse) GetPoolId() string { + if x != nil { + return x.PoolId + } + return "" +} + type LuckyGiftDrawResult struct { state protoimpl.MessageState `protogen:"open.v1"` DrawId string `protobuf:"bytes,1,opt,name=draw_id,json=drawId,proto3" json:"draw_id,omitempty"` @@ -9595,6 +9643,7 @@ type LuckyGiftDrawResult struct { StageFeedback bool `protobuf:"varint,17,opt,name=stage_feedback,json=stageFeedback,proto3" json:"stage_feedback,omitempty"` HighMultiplier bool `protobuf:"varint,18,opt,name=high_multiplier,json=highMultiplier,proto3" json:"high_multiplier,omitempty"` CreatedAtMs int64 `protobuf:"varint,19,opt,name=created_at_ms,json=createdAtMs,proto3" json:"created_at_ms,omitempty"` + PoolId string `protobuf:"bytes,20,opt,name=pool_id,json=poolId,proto3" json:"pool_id,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -9762,6 +9811,13 @@ func (x *LuckyGiftDrawResult) GetCreatedAtMs() int64 { return 0 } +func (x *LuckyGiftDrawResult) GetPoolId() string { + if x != nil { + return x.PoolId + } + return "" +} + type ExecuteLuckyGiftDrawRequest struct { state protoimpl.MessageState `protogen:"open.v1"` LuckyGift *LuckyGiftMeta `protobuf:"bytes,1,opt,name=lucky_gift,json=luckyGift,proto3" json:"lucky_gift,omitempty"` @@ -9854,6 +9910,7 @@ type GetLuckyGiftConfigRequest struct { state protoimpl.MessageState `protogen:"open.v1"` Meta *RequestMeta `protobuf:"bytes,1,opt,name=meta,proto3" json:"meta,omitempty"` GiftId string `protobuf:"bytes,2,opt,name=gift_id,json=giftId,proto3" json:"gift_id,omitempty"` + PoolId string `protobuf:"bytes,3,opt,name=pool_id,json=poolId,proto3" json:"pool_id,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -9902,6 +9959,13 @@ func (x *GetLuckyGiftConfigRequest) GetGiftId() string { return "" } +func (x *GetLuckyGiftConfigRequest) GetPoolId() string { + if x != nil { + return x.PoolId + } + return "" +} + type GetLuckyGiftConfigResponse struct { state protoimpl.MessageState `protogen:"open.v1"` Config *LuckyGiftConfig `protobuf:"bytes,1,opt,name=config,proto3" json:"config,omitempty"` @@ -10059,6 +10123,7 @@ type ListLuckyGiftDrawsRequest struct { Status string `protobuf:"bytes,5,opt,name=status,proto3" json:"status,omitempty"` Page int32 `protobuf:"varint,6,opt,name=page,proto3" json:"page,omitempty"` PageSize int32 `protobuf:"varint,7,opt,name=page_size,json=pageSize,proto3" json:"page_size,omitempty"` + PoolId string `protobuf:"bytes,8,opt,name=pool_id,json=poolId,proto3" json:"pool_id,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -10142,6 +10207,13 @@ func (x *ListLuckyGiftDrawsRequest) GetPageSize() int32 { return 0 } +func (x *ListLuckyGiftDrawsRequest) GetPoolId() string { + if x != nil { + return x.PoolId + } + return "" +} + type ListLuckyGiftDrawsResponse struct { state protoimpl.MessageState `protogen:"open.v1"` Draws []*LuckyGiftDrawResult `protobuf:"bytes,1,rep,name=draws,proto3" json:"draws,omitempty"` @@ -11014,7 +11086,7 @@ const file_proto_activity_v1_activity_proto_rawDesc = "" + "conditions\"\x8b\x01\n" + "#UpsertAchievementDefinitionResponse\x12J\n" + "\vachievement\x18\x01 \x01(\v2(.hyapp.activity.v1.AchievementDefinitionR\vachievement\x12\x18\n" + - "\acreated\x18\x02 \x01(\bR\acreated\"\xa4\x02\n" + + "\acreated\x18\x02 \x01(\bR\acreated\"\xbd\x02\n" + "\rLuckyGiftMeta\x122\n" + "\x04meta\x18\x01 \x01(\v2\x1e.hyapp.activity.v1.RequestMetaR\x04meta\x12\x1d\n" + "\n" + @@ -11027,14 +11099,17 @@ const file_proto_activity_v1_activity_proto_rawDesc = "" + "\n" + "coin_spent\x18\b \x01(\x03R\tcoinSpent\x12\x1c\n" + "\n" + - "paid_at_ms\x18\t \x01(\x03R\bpaidAtMs\"\xb9\x01\n" + + "paid_at_ms\x18\t \x01(\x03R\bpaidAtMs\x12\x17\n" + + "\apool_id\x18\n" + + " \x01(\tR\x06poolId\"\xe0\x01\n" + "\rLuckyGiftTier\x12\x12\n" + "\x04pool\x18\x01 \x01(\tR\x04pool\x12\x17\n" + "\atier_id\x18\x02 \x01(\tR\x06tierId\x12!\n" + "\freward_coins\x18\x03 \x01(\x03R\vrewardCoins\x12\x16\n" + "\x06weight\x18\x04 \x01(\x03R\x06weight\x12&\n" + "\x0fhigh_water_only\x18\x05 \x01(\bR\rhighWaterOnly\x12\x18\n" + - "\aenabled\x18\x06 \x01(\bR\aenabled\"\xbd\r\n" + + "\aenabled\x18\x06 \x01(\bR\aenabled\x12%\n" + + "\x0emultiplier_ppm\x18\a \x01(\x03R\rmultiplierPpm\"\xff\r\n" + "\x0fLuckyGiftConfig\x12\x19\n" + "\bapp_code\x18\x01 \x01(\tR\aappCode\x12\x17\n" + "\agift_id\x18\x02 \x01(\tR\x06giftId\x12\x18\n" + @@ -11075,12 +11150,15 @@ const file_proto_activity_v1_activity_proto_rawDesc = "" + "\x05tiers\x18# \x03(\v2 .hyapp.activity.v1.LuckyGiftTierR\x05tiers\x12-\n" + "\x13updated_by_admin_id\x18$ \x01(\x03R\x10updatedByAdminId\x12\"\n" + "\rcreated_at_ms\x18% \x01(\x03R\vcreatedAtMs\x12\"\n" + - "\rupdated_at_ms\x18& \x01(\x03R\vupdatedAtMs\"\x96\x01\n" + + "\rupdated_at_ms\x18& \x01(\x03R\vupdatedAtMs\x12\x17\n" + + "\apool_id\x18' \x01(\tR\x06poolId\x12'\n" + + "\x0fmultiplier_ppms\x18( \x03(\x03R\x0emultiplierPpms\"\xaf\x01\n" + "\x15CheckLuckyGiftRequest\x122\n" + "\x04meta\x18\x01 \x01(\v2\x1e.hyapp.activity.v1.RequestMetaR\x04meta\x12\x17\n" + "\auser_id\x18\x02 \x01(\x03R\x06userId\x12\x17\n" + "\aroom_id\x18\x03 \x01(\tR\x06roomId\x12\x17\n" + - "\agift_id\x18\x04 \x01(\tR\x06giftId\"\xf4\x01\n" + + "\agift_id\x18\x04 \x01(\tR\x06giftId\x12\x17\n" + + "\apool_id\x18\x05 \x01(\tR\x06poolId\"\x8d\x02\n" + "\x16CheckLuckyGiftResponse\x12\x18\n" + "\aenabled\x18\x01 \x01(\bR\aenabled\x12\x16\n" + "\x06reason\x18\x02 \x01(\tR\x06reason\x12\x17\n" + @@ -11089,7 +11167,8 @@ const file_proto_activity_v1_activity_proto_rawDesc = "" + "gift_price\x18\x04 \x01(\x03R\tgiftPrice\x12!\n" + "\frule_version\x18\x05 \x01(\x03R\vruleVersion\x12$\n" + "\x0etarget_rtp_ppm\x18\x06 \x01(\x03R\ftargetRtpPpm\x12'\n" + - "\x0fexperience_pool\x18\a \x01(\tR\x0eexperiencePool\"\xb5\x06\n" + + "\x0fexperience_pool\x18\a \x01(\tR\x0eexperiencePool\x12\x17\n" + + "\apool_id\x18\b \x01(\tR\x06poolId\"\xce\x06\n" + "\x13LuckyGiftDrawResult\x12\x17\n" + "\adraw_id\x18\x01 \x01(\tR\x06drawId\x12\x1d\n" + "\n" + @@ -11111,15 +11190,17 @@ const file_proto_activity_v1_activity_proto_rawDesc = "" + "\x11gift_base_rtp_ppm\x18\x10 \x01(\x03R\x0egiftBaseRtpPpm\x12%\n" + "\x0estage_feedback\x18\x11 \x01(\bR\rstageFeedback\x12'\n" + "\x0fhigh_multiplier\x18\x12 \x01(\bR\x0ehighMultiplier\x12\"\n" + - "\rcreated_at_ms\x18\x13 \x01(\x03R\vcreatedAtMs\"^\n" + + "\rcreated_at_ms\x18\x13 \x01(\x03R\vcreatedAtMs\x12\x17\n" + + "\apool_id\x18\x14 \x01(\tR\x06poolId\"^\n" + "\x1bExecuteLuckyGiftDrawRequest\x12?\n" + "\n" + "lucky_gift\x18\x01 \x01(\v2 .hyapp.activity.v1.LuckyGiftMetaR\tluckyGift\"^\n" + "\x1cExecuteLuckyGiftDrawResponse\x12>\n" + - "\x06result\x18\x01 \x01(\v2&.hyapp.activity.v1.LuckyGiftDrawResultR\x06result\"h\n" + + "\x06result\x18\x01 \x01(\v2&.hyapp.activity.v1.LuckyGiftDrawResultR\x06result\"\x81\x01\n" + "\x19GetLuckyGiftConfigRequest\x122\n" + "\x04meta\x18\x01 \x01(\v2\x1e.hyapp.activity.v1.RequestMetaR\x04meta\x12\x17\n" + - "\agift_id\x18\x02 \x01(\tR\x06giftId\"X\n" + + "\agift_id\x18\x02 \x01(\tR\x06giftId\x12\x17\n" + + "\apool_id\x18\x03 \x01(\tR\x06poolId\"X\n" + "\x1aGetLuckyGiftConfigResponse\x12:\n" + "\x06config\x18\x01 \x01(\v2\".hyapp.activity.v1.LuckyGiftConfigR\x06config\"\xba\x01\n" + "\x1cUpsertLuckyGiftConfigRequest\x122\n" + @@ -11127,7 +11208,7 @@ const file_proto_activity_v1_activity_proto_rawDesc = "" + "\x06config\x18\x02 \x01(\v2\".hyapp.activity.v1.LuckyGiftConfigR\x06config\x12*\n" + "\x11operator_admin_id\x18\x03 \x01(\x03R\x0foperatorAdminId\"[\n" + "\x1dUpsertLuckyGiftConfigResponse\x12:\n" + - "\x06config\x18\x01 \x01(\v2\".hyapp.activity.v1.LuckyGiftConfigR\x06config\"\xe3\x01\n" + + "\x06config\x18\x01 \x01(\v2\".hyapp.activity.v1.LuckyGiftConfigR\x06config\"\xfc\x01\n" + "\x19ListLuckyGiftDrawsRequest\x122\n" + "\x04meta\x18\x01 \x01(\v2\x1e.hyapp.activity.v1.RequestMetaR\x04meta\x12\x17\n" + "\agift_id\x18\x02 \x01(\tR\x06giftId\x12\x17\n" + @@ -11135,7 +11216,8 @@ const file_proto_activity_v1_activity_proto_rawDesc = "" + "\aroom_id\x18\x04 \x01(\tR\x06roomId\x12\x16\n" + "\x06status\x18\x05 \x01(\tR\x06status\x12\x12\n" + "\x04page\x18\x06 \x01(\x05R\x04page\x12\x1b\n" + - "\tpage_size\x18\a \x01(\x05R\bpageSize\"p\n" + + "\tpage_size\x18\a \x01(\x05R\bpageSize\x12\x17\n" + + "\apool_id\x18\b \x01(\tR\x06poolId\"p\n" + "\x1aListLuckyGiftDrawsResponse\x12<\n" + "\x05draws\x18\x01 \x03(\v2&.hyapp.activity.v1.LuckyGiftDrawResultR\x05draws\x12\x14\n" + "\x05total\x18\x02 \x01(\x03R\x05total2\xe2\x01\n" + diff --git a/api/proto/activity/v1/activity.proto b/api/proto/activity/v1/activity.proto index e5a82915..e5fa6e98 100644 --- a/api/proto/activity/v1/activity.proto +++ b/api/proto/activity/v1/activity.proto @@ -1006,6 +1006,7 @@ message LuckyGiftMeta { string gift_id = 7; int64 coin_spent = 8; int64 paid_at_ms = 9; + string pool_id = 10; } message LuckyGiftTier { @@ -1015,6 +1016,7 @@ message LuckyGiftTier { int64 weight = 4; bool high_water_only = 5; bool enabled = 6; + int64 multiplier_ppm = 7; } message LuckyGiftConfig { @@ -1056,6 +1058,8 @@ message LuckyGiftConfig { int64 updated_by_admin_id = 36; int64 created_at_ms = 37; int64 updated_at_ms = 38; + string pool_id = 39; + repeated int64 multiplier_ppms = 40; } message CheckLuckyGiftRequest { @@ -1063,6 +1067,7 @@ message CheckLuckyGiftRequest { int64 user_id = 2; string room_id = 3; string gift_id = 4; + string pool_id = 5; } message CheckLuckyGiftResponse { @@ -1073,6 +1078,7 @@ message CheckLuckyGiftResponse { int64 rule_version = 5; int64 target_rtp_ppm = 6; string experience_pool = 7; + string pool_id = 8; } message LuckyGiftDrawResult { @@ -1095,6 +1101,7 @@ message LuckyGiftDrawResult { bool stage_feedback = 17; bool high_multiplier = 18; int64 created_at_ms = 19; + string pool_id = 20; } message ExecuteLuckyGiftDrawRequest { @@ -1108,6 +1115,7 @@ message ExecuteLuckyGiftDrawResponse { message GetLuckyGiftConfigRequest { RequestMeta meta = 1; string gift_id = 2; + string pool_id = 3; } message GetLuckyGiftConfigResponse { @@ -1132,6 +1140,7 @@ message ListLuckyGiftDrawsRequest { string status = 5; int32 page = 6; int32 page_size = 7; + string pool_id = 8; } message ListLuckyGiftDrawsResponse { diff --git a/api/proto/activity/v1/activity_grpc.pb.go b/api/proto/activity/v1/activity_grpc.pb.go index 70cf6ad1..9832ce51 100644 --- a/api/proto/activity/v1/activity_grpc.pb.go +++ b/api/proto/activity/v1/activity_grpc.pb.go @@ -1,7 +1,7 @@ // Code generated by protoc-gen-go-grpc. DO NOT EDIT. // versions: // - protoc-gen-go-grpc v1.6.2 -// - protoc v5.28.3 +// - protoc (unknown) // source: proto/activity/v1/activity.proto package activityv1 diff --git a/api/proto/events/room/v1/events.pb.go b/api/proto/events/room/v1/events.pb.go index 9c0ff95a..7ba6aeca 100644 --- a/api/proto/events/room/v1/events.pb.go +++ b/api/proto/events/room/v1/events.pb.go @@ -971,6 +971,7 @@ type RoomGiftSent struct { BillingReceiptId string `protobuf:"bytes,6,opt,name=billing_receipt_id,json=billingReceiptId,proto3" json:"billing_receipt_id,omitempty"` VisibleRegionId int64 `protobuf:"varint,7,opt,name=visible_region_id,json=visibleRegionId,proto3" json:"visible_region_id,omitempty"` CommandId string `protobuf:"bytes,8,opt,name=command_id,json=commandId,proto3" json:"command_id,omitempty"` + PoolId string `protobuf:"bytes,9,opt,name=pool_id,json=poolId,proto3" json:"pool_id,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -1061,6 +1062,13 @@ func (x *RoomGiftSent) GetCommandId() string { return "" } +func (x *RoomGiftSent) GetPoolId() string { + if x != nil { + return x.PoolId + } + return "" +} + // RoomHeatChanged 表达热度变化结果。 type RoomHeatChanged struct { state protoimpl.MessageState `protogen:"open.v1"` @@ -1797,7 +1805,7 @@ const file_proto_events_room_v1_events_proto_rawDesc = "" + "\x0etarget_user_id\x18\x02 \x01(\x03R\ftargetUserId\"\\\n" + "\x10RoomUserUnbanned\x12\"\n" + "\ractor_user_id\x18\x01 \x01(\x03R\vactorUserId\x12$\n" + - "\x0etarget_user_id\x18\x02 \x01(\x03R\ftargetUserId\"\xaa\x02\n" + + "\x0etarget_user_id\x18\x02 \x01(\x03R\ftargetUserId\"\xc3\x02\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" + @@ -1809,7 +1817,8 @@ const file_proto_events_room_v1_events_proto_rawDesc = "" + "\x12billing_receipt_id\x18\x06 \x01(\tR\x10billingReceiptId\x12*\n" + "\x11visible_region_id\x18\a \x01(\x03R\x0fvisibleRegionId\x12\x1d\n" + "\n" + - "command_id\x18\b \x01(\tR\tcommandId\"J\n" + + "command_id\x18\b \x01(\tR\tcommandId\x12\x17\n" + + "\apool_id\x18\t \x01(\tR\x06poolId\"J\n" + "\x0fRoomHeatChanged\x12\x14\n" + "\x05delta\x18\x01 \x01(\x03R\x05delta\x12!\n" + "\fcurrent_heat\x18\x02 \x01(\x03R\vcurrentHeat\"_\n" + diff --git a/api/proto/events/room/v1/events.proto b/api/proto/events/room/v1/events.proto index 8ce98f2f..9b004c17 100644 --- a/api/proto/events/room/v1/events.proto +++ b/api/proto/events/room/v1/events.proto @@ -127,6 +127,7 @@ message RoomGiftSent { string billing_receipt_id = 6; int64 visible_region_id = 7; string command_id = 8; + string pool_id = 9; } // RoomHeatChanged 表达热度变化结果。 diff --git a/api/proto/game/v1/game.pb.go b/api/proto/game/v1/game.pb.go index 264800bf..b640821f 100644 --- a/api/proto/game/v1/game.pb.go +++ b/api/proto/game/v1/game.pb.go @@ -1,7 +1,7 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: // protoc-gen-go v1.36.11 -// protoc v5.28.3 +// protoc (unknown) // source: proto/game/v1/game.proto package gamev1 @@ -107,17 +107,23 @@ func (x *RequestMeta) GetAppCode() string { } type GamePlatform struct { - state protoimpl.MessageState `protogen:"open.v1"` - AppCode string `protobuf:"bytes,1,opt,name=app_code,json=appCode,proto3" json:"app_code,omitempty"` - PlatformCode string `protobuf:"bytes,2,opt,name=platform_code,json=platformCode,proto3" json:"platform_code,omitempty"` - PlatformName string `protobuf:"bytes,3,opt,name=platform_name,json=platformName,proto3" json:"platform_name,omitempty"` - Status string `protobuf:"bytes,4,opt,name=status,proto3" json:"status,omitempty"` - ApiBaseUrl string `protobuf:"bytes,5,opt,name=api_base_url,json=apiBaseUrl,proto3" json:"api_base_url,omitempty"` - SortOrder int32 `protobuf:"varint,6,opt,name=sort_order,json=sortOrder,proto3" json:"sort_order,omitempty"` - CreatedAtMs int64 `protobuf:"varint,7,opt,name=created_at_ms,json=createdAtMs,proto3" json:"created_at_ms,omitempty"` - UpdatedAtMs int64 `protobuf:"varint,8,opt,name=updated_at_ms,json=updatedAtMs,proto3" json:"updated_at_ms,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + state protoimpl.MessageState `protogen:"open.v1"` + AppCode string `protobuf:"bytes,1,opt,name=app_code,json=appCode,proto3" json:"app_code,omitempty"` + PlatformCode string `protobuf:"bytes,2,opt,name=platform_code,json=platformCode,proto3" json:"platform_code,omitempty"` + PlatformName string `protobuf:"bytes,3,opt,name=platform_name,json=platformName,proto3" json:"platform_name,omitempty"` + Status string `protobuf:"bytes,4,opt,name=status,proto3" json:"status,omitempty"` + ApiBaseUrl string `protobuf:"bytes,5,opt,name=api_base_url,json=apiBaseUrl,proto3" json:"api_base_url,omitempty"` + SortOrder int32 `protobuf:"varint,6,opt,name=sort_order,json=sortOrder,proto3" json:"sort_order,omitempty"` + CreatedAtMs int64 `protobuf:"varint,7,opt,name=created_at_ms,json=createdAtMs,proto3" json:"created_at_ms,omitempty"` + UpdatedAtMs int64 `protobuf:"varint,8,opt,name=updated_at_ms,json=updatedAtMs,proto3" json:"updated_at_ms,omitempty"` + AdapterType string `protobuf:"bytes,9,opt,name=adapter_type,json=adapterType,proto3" json:"adapter_type,omitempty"` + // callback_secret is write-only for admin updates; list responses keep it empty. + CallbackSecret string `protobuf:"bytes,10,opt,name=callback_secret,json=callbackSecret,proto3" json:"callback_secret,omitempty"` + CallbackSecretSet bool `protobuf:"varint,11,opt,name=callback_secret_set,json=callbackSecretSet,proto3" json:"callback_secret_set,omitempty"` + CallbackIpWhitelist []string `protobuf:"bytes,12,rep,name=callback_ip_whitelist,json=callbackIpWhitelist,proto3" json:"callback_ip_whitelist,omitempty"` + AdapterConfigJson string `protobuf:"bytes,13,opt,name=adapter_config_json,json=adapterConfigJson,proto3" json:"adapter_config_json,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *GamePlatform) Reset() { @@ -206,6 +212,41 @@ func (x *GamePlatform) GetUpdatedAtMs() int64 { return 0 } +func (x *GamePlatform) GetAdapterType() string { + if x != nil { + return x.AdapterType + } + return "" +} + +func (x *GamePlatform) GetCallbackSecret() string { + if x != nil { + return x.CallbackSecret + } + return "" +} + +func (x *GamePlatform) GetCallbackSecretSet() bool { + if x != nil { + return x.CallbackSecretSet + } + return false +} + +func (x *GamePlatform) GetCallbackIpWhitelist() []string { + if x != nil { + return x.CallbackIpWhitelist + } + return nil +} + +func (x *GamePlatform) GetAdapterConfigJson() string { + if x != nil { + return x.AdapterConfigJson + } + return "" +} + type GameCatalogItem struct { state protoimpl.MessageState `protogen:"open.v1"` AppCode string `protobuf:"bytes,1,opt,name=app_code,json=appCode,proto3" json:"app_code,omitempty"` @@ -1631,7 +1672,7 @@ const file_proto_game_v1_game_proto_rawDesc = "" + "\ractor_user_id\x18\x04 \x01(\x03R\vactorUserId\x12\x1c\n" + "\n" + "sent_at_ms\x18\x05 \x01(\x03R\bsentAtMs\x12\x19\n" + - "\bapp_code\x18\x06 \x01(\tR\aappCode\"\x94\x02\n" + + "\bapp_code\x18\x06 \x01(\tR\aappCode\"\xf4\x03\n" + "\fGamePlatform\x12\x19\n" + "\bapp_code\x18\x01 \x01(\tR\aappCode\x12#\n" + "\rplatform_code\x18\x02 \x01(\tR\fplatformCode\x12#\n" + @@ -1642,7 +1683,13 @@ const file_proto_game_v1_game_proto_rawDesc = "" + "\n" + "sort_order\x18\x06 \x01(\x05R\tsortOrder\x12\"\n" + "\rcreated_at_ms\x18\a \x01(\x03R\vcreatedAtMs\x12\"\n" + - "\rupdated_at_ms\x18\b \x01(\x03R\vupdatedAtMs\"\xf6\x03\n" + + "\rupdated_at_ms\x18\b \x01(\x03R\vupdatedAtMs\x12!\n" + + "\fadapter_type\x18\t \x01(\tR\vadapterType\x12'\n" + + "\x0fcallback_secret\x18\n" + + " \x01(\tR\x0ecallbackSecret\x12.\n" + + "\x13callback_secret_set\x18\v \x01(\bR\x11callbackSecretSet\x122\n" + + "\x15callback_ip_whitelist\x18\f \x03(\tR\x13callbackIpWhitelist\x12.\n" + + "\x13adapter_config_json\x18\r \x01(\tR\x11adapterConfigJson\"\xf6\x03\n" + "\x0fGameCatalogItem\x12\x19\n" + "\bapp_code\x18\x01 \x01(\tR\aappCode\x12\x17\n" + "\agame_id\x18\x02 \x01(\tR\x06gameId\x12#\n" + diff --git a/api/proto/game/v1/game.proto b/api/proto/game/v1/game.proto index e31bd75f..a358dd68 100644 --- a/api/proto/game/v1/game.proto +++ b/api/proto/game/v1/game.proto @@ -23,6 +23,12 @@ message GamePlatform { int32 sort_order = 6; int64 created_at_ms = 7; int64 updated_at_ms = 8; + string adapter_type = 9; + // callback_secret is write-only for admin updates; list responses keep it empty. + string callback_secret = 10; + bool callback_secret_set = 11; + repeated string callback_ip_whitelist = 12; + string adapter_config_json = 13; } message GameCatalogItem { diff --git a/api/proto/game/v1/game_grpc.pb.go b/api/proto/game/v1/game_grpc.pb.go index 94c08dc1..a1abab72 100644 --- a/api/proto/game/v1/game_grpc.pb.go +++ b/api/proto/game/v1/game_grpc.pb.go @@ -1,7 +1,7 @@ // Code generated by protoc-gen-go-grpc. DO NOT EDIT. // versions: // - protoc-gen-go-grpc v1.6.2 -// - protoc v5.28.3 +// - protoc (unknown) // source: proto/game/v1/game.proto package gamev1 diff --git a/api/proto/room/v1/room.pb.go b/api/proto/room/v1/room.pb.go index 8095474f..01a11fd1 100644 --- a/api/proto/room/v1/room.pb.go +++ b/api/proto/room/v1/room.pb.go @@ -3657,6 +3657,7 @@ type SendGiftRequest struct { // target_type 当前只提交 user;后续扩展 all_mic、all_room、couple 时不再改 HTTP 契约。 TargetType string `protobuf:"bytes,5,opt,name=target_type,json=targetType,proto3" json:"target_type,omitempty"` TargetUserIds []int64 `protobuf:"varint,6,rep,packed,name=target_user_ids,json=targetUserIds,proto3" json:"target_user_ids,omitempty"` + PoolId string `protobuf:"bytes,7,opt,name=pool_id,json=poolId,proto3" json:"pool_id,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -3733,6 +3734,13 @@ func (x *SendGiftRequest) GetTargetUserIds() []int64 { return nil } +func (x *SendGiftRequest) GetPoolId() string { + if x != nil { + return x.PoolId + } + return "" +} + // SendGiftResponse 返回扣费成功并落到房间后的状态结果。 type SendGiftResponse struct { state protoimpl.MessageState `protogen:"open.v1"` @@ -5750,7 +5758,7 @@ const file_proto_room_v1_room_proto_rawDesc = "" + "\aroom_id\x18\x04 \x01(\tR\x06roomId\x12\x1d\n" + "\n" + "rtc_kicked\x18\x05 \x01(\bR\trtcKicked\x12$\n" + - "\x0ertc_kick_error\x18\x06 \x01(\tR\frtcKickError\"\xe8\x01\n" + + "\x0ertc_kick_error\x18\x06 \x01(\tR\frtcKickError\"\x81\x02\n" + "\x0fSendGiftRequest\x12.\n" + "\x04meta\x18\x01 \x01(\v2\x1a.hyapp.room.v1.RequestMetaR\x04meta\x12$\n" + "\x0etarget_user_id\x18\x02 \x01(\x03R\ftargetUserId\x12\x17\n" + @@ -5759,7 +5767,8 @@ const file_proto_room_v1_room_proto_rawDesc = "" + "gift_count\x18\x04 \x01(\x05R\tgiftCount\x12\x1f\n" + "\vtarget_type\x18\x05 \x01(\tR\n" + "targetType\x12&\n" + - "\x0ftarget_user_ids\x18\x06 \x03(\x03R\rtargetUserIds\"\xb8\x02\n" + + "\x0ftarget_user_ids\x18\x06 \x03(\x03R\rtargetUserIds\x12\x17\n" + + "\apool_id\x18\a \x01(\tR\x06poolId\"\xb8\x02\n" + "\x10SendGiftResponse\x124\n" + "\x06result\x18\x01 \x01(\v2\x1c.hyapp.room.v1.CommandResultR\x06result\x12,\n" + "\x12billing_receipt_id\x18\x02 \x01(\tR\x10billingReceiptId\x12\x1b\n" + diff --git a/api/proto/room/v1/room.proto b/api/proto/room/v1/room.proto index 9b0757e0..988af86c 100644 --- a/api/proto/room/v1/room.proto +++ b/api/proto/room/v1/room.proto @@ -465,6 +465,7 @@ message SendGiftRequest { // target_type 当前只提交 user;后续扩展 all_mic、all_room、couple 时不再改 HTTP 契约。 string target_type = 5; repeated int64 target_user_ids = 6; + string pool_id = 7; } // SendGiftResponse 返回扣费成功并落到房间后的状态结果。 diff --git a/api/proto/user/v1/auth.pb.go b/api/proto/user/v1/auth.pb.go index a2f56167..23371b07 100644 --- a/api/proto/user/v1/auth.pb.go +++ b/api/proto/user/v1/auth.pb.go @@ -1,7 +1,7 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: // protoc-gen-go v1.36.11 -// protoc v5.28.3 +// protoc (unknown) // source: proto/user/v1/auth.proto package userv1 diff --git a/api/proto/user/v1/auth_grpc.pb.go b/api/proto/user/v1/auth_grpc.pb.go index 775c0660..b9036787 100644 --- a/api/proto/user/v1/auth_grpc.pb.go +++ b/api/proto/user/v1/auth_grpc.pb.go @@ -1,7 +1,7 @@ // Code generated by protoc-gen-go-grpc. DO NOT EDIT. // versions: // - protoc-gen-go-grpc v1.6.2 -// - protoc v5.28.3 +// - protoc (unknown) // source: proto/user/v1/auth.proto package userv1 diff --git a/api/proto/user/v1/host.pb.go b/api/proto/user/v1/host.pb.go index 7c1919b7..5c68165c 100644 --- a/api/proto/user/v1/host.pb.go +++ b/api/proto/user/v1/host.pb.go @@ -1,7 +1,7 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: // protoc-gen-go v1.36.11 -// protoc v5.28.3 +// protoc (unknown) // source: proto/user/v1/host.proto package userv1 diff --git a/api/proto/user/v1/host_grpc.pb.go b/api/proto/user/v1/host_grpc.pb.go index 8cffb947..cc4730d4 100644 --- a/api/proto/user/v1/host_grpc.pb.go +++ b/api/proto/user/v1/host_grpc.pb.go @@ -1,7 +1,7 @@ // Code generated by protoc-gen-go-grpc. DO NOT EDIT. // versions: // - protoc-gen-go-grpc v1.6.2 -// - protoc v5.28.3 +// - protoc (unknown) // source: proto/user/v1/host.proto package userv1 diff --git a/api/proto/user/v1/user.pb.go b/api/proto/user/v1/user.pb.go index ee6f30c6..26aace9f 100644 --- a/api/proto/user/v1/user.pb.go +++ b/api/proto/user/v1/user.pb.go @@ -1,7 +1,7 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: // protoc-gen-go v1.36.11 -// protoc v5.28.3 +// protoc (unknown) // source: proto/user/v1/user.proto package userv1 diff --git a/api/proto/user/v1/user_grpc.pb.go b/api/proto/user/v1/user_grpc.pb.go index 3d295c13..9d79ef93 100644 --- a/api/proto/user/v1/user_grpc.pb.go +++ b/api/proto/user/v1/user_grpc.pb.go @@ -1,7 +1,7 @@ // Code generated by protoc-gen-go-grpc. DO NOT EDIT. // versions: // - protoc-gen-go-grpc v1.6.2 -// - protoc v5.28.3 +// - protoc (unknown) // source: proto/user/v1/user.proto package userv1 diff --git a/deploy/README.md b/deploy/README.md index 2ff7642c..bbdaf75b 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -32,7 +32,8 @@ room-service 实例之间通过 Redis owner route 和 node registry 直连转发 | 层级 | 地址 | 说明 | | --- | --- | --- | | 公网 API | `https://api.global-interaction.com` | HTTPS 443 由公网 CLB 转发到两台 `gateway-service:13000` | -| 后台平台 | `https://hyapp-platform.global-interaction.com` | 香港 Lighthouse 上 nginx 终止 HTTPS,反代本机 `admin-server:13100` | +| 后台前端 | `http://1.14.164.2:7001` | 广州 CVM `172.16.0.10` 上 nginx 托管 `admin-platform` 静态资源,`/api` 反代内网后台服务 | +| 后台 API | `172.16.0.6:13100` | 广州 CVM `ins-0x5mjwmc` 运行 `hyapp-admin-server`,通过 `wg-quick@hyapp-admin` 访问生产内网依赖 | | room/user/activity/game/notice 内网入口 | `10.2.1.16:13001/13005/13006/13008/13009` | `lb-epvnr4o0` 的 Go 服务监听器 | | wallet 内网入口 | `10.2.1.15:13004` | `lb-4f5xi6p0` 的 Go 服务监听器 | diff --git a/docs/flutter对接/语音房宝箱Flutter对接.md b/docs/flutter对接/语音房宝箱Flutter对接.md new file mode 100644 index 00000000..00ae1ffe --- /dev/null +++ b/docs/flutter对接/语音房宝箱Flutter对接.md @@ -0,0 +1,611 @@ +# 语音房宝箱 Flutter 对接 + +本文描述 Flutter App 对接语音房宝箱所需的 HTTP 接口、请求参数、返回值、错误处理和腾讯云 IM 自定义消息 key。宝箱进度、开箱和发奖事实由 `room-service` Room Cell 持有;客户端只展示服务端返回或 IM 推送的状态,不能本地计算能量、奖励或开箱结果。 + +## 基础约定 + +本地开发地址: + +```text +http://127.0.0.1:13000 +``` + +业务接口统一响应 envelope: + +```json +{ + "code": "OK", + "message": "ok", + "request_id": "req_xxx", + "data": {} +} +``` + +| 字段 | 说明 | +| --- | --- | +| `code` | 成功固定 `OK`;失败为稳定错误码。 | +| `message` | 短错误文案,不用于客户端分支判断。 | +| `request_id` | 服务端链路追踪 ID;客户端日志要记录。 | +| `data` | 成功业务数据;失败时通常不存在。 | + +通用请求头: + +| Header | 必填 | 说明 | +| --- | --- | --- | +| `Authorization` | 是 | `Bearer `。 | +| `X-App-Code` | 否 | App 编码,默认 `lalu`;登录态接口最终以 token 内 app_code 为准。 | +| `Content-Type` | POST 必填 | `application/json`。 | + +所有 `*_ms` 都是 Unix epoch milliseconds。业务切日按 UTC,不按用户本地时区。 + +## 客户端流程 + +1. 用户成功 `JoinRoom` 后调用 `GET /api/v1/rooms/{room_id}/treasure` 初始化 7 级物料和当前进度。 +2. 用户送礼调用 `POST /api/v1/rooms/gift/send`;成功响应里的 `data.treasure` 可立即更新当前宝箱状态。 +3. 房间内监听腾讯 IM 群 `TIMCustomElem.Ext=room_system_message`,处理宝箱进度、倒计时、开箱和发奖。 +4. App 全局或区域播报监听 `TIMCustomElem.Ext=im_broadcast` 且 `broadcast_type=room_treasure`。 +5. 任意 IM 丢失、字段解析失败、版本倒退或页面重进时,重新调用 `GET /api/v1/rooms/{room_id}/treasure` 纠正本地状态。 + +客户端必须用 `event_id` 去重,用 `room_version` 丢弃旧房间消息。 + +## 获取宝箱信息 + +```http +GET /api/v1/rooms/{room_id}/treasure +Authorization: Bearer +X-App-Code: lalu +``` + +路径参数: + +| 参数 | 类型 | 必填 | 说明 | +| --- | --- | --- | --- | +| `room_id` | string | 是 | 当前语音房 ID,例如 `lalu_534d076a-7cbd-4b23-a297-0c10a3c2fa72`。 | + +成功响应: + +```json +{ + "code": "OK", + "message": "ok", + "request_id": "req_xxx", + "data": { + "enabled": true, + "levels": [ + { + "level": 1, + "energy_threshold": 1000, + "cover_url": "https://cdn.example/chest/lv1/cover.png", + "animation_url": "https://cdn.example/chest/lv1/idle.svga", + "opening_animation_url": "https://cdn.example/chest/lv1/opening.svga", + "opened_image_url": "https://cdn.example/chest/lv1/opened.png", + "in_room_rewards": [ + { + "reward_item_id": "lv1_in_room_1", + "resource_group_id": 1001, + "weight": 10000, + "display_name": "Gold Pack", + "icon_url": "https://cdn.example/reward/gold.png" + } + ], + "top1_rewards": [], + "igniter_rewards": [] + } + ], + "state": { + "current_level": 1, + "current_progress": 0, + "energy_threshold": 1000, + "status": "idle", + "reset_at_ms": 1779321600000, + "top1_user_id": "", + "igniter_user_id": "", + "box_id": "", + "config_version": 3, + "last_rewards": [] + }, + "broadcast_scope": "region", + "open_delay_ms": 30000, + "broadcast_delay_ms": 0, + "reward_stack_policy": "allow_stack", + "server_time_ms": 1779259000000 + } +} +``` + +`data` 字段: + +| 字段 | 类型 | 说明 | +| --- | --- | --- | +| `enabled` | bool | 宝箱总开关。`false` 时客户端隐藏或置灰宝箱入口。 | +| `levels` | array | 固定 7 个等级的物料、阈值和奖励池。 | +| `state` | object | 当前房间当前 UTC 日的宝箱状态。 | +| `broadcast_scope` | string | `none`、`region`、`global`。 | +| `open_delay_ms` | int64 | 满能量后到开箱的倒计时配置。 | +| `broadcast_delay_ms` | int64 | 后台配置字段;当前房间事件会立即进入播报链路。 | +| `reward_stack_policy` | string | `allow_stack` 或 `priority_only`。 | +| `server_time_ms` | int64 | 服务端当前时间,倒计时要以它校准本地时钟偏差。 | + +`levels[]` 字段: + +| 字段 | 类型 | 说明 | +| --- | --- | --- | +| `level` | int32 | 等级,1 到 7。 | +| `energy_threshold` | int64 | 当前等级满能量阈值。 | +| `cover_url` | string | 静态封面图。 | +| `animation_url` | string | 未开箱/蓄能态动效。 | +| `opening_animation_url` | string | 开箱动效。 | +| `opened_image_url` | string | 开箱后展示图。 | +| `in_room_rewards` | array | 开箱时仍在房用户的奖励候选展示。 | +| `top1_rewards` | array | 倒计时开始时贡献第一用户奖励候选展示。 | +| `igniter_rewards` | array | 点火人奖励候选展示。 | + +奖励候选字段: + +| 字段 | 类型 | 说明 | +| --- | --- | --- | +| `reward_item_id` | string | 后台配置的奖励项 ID。 | +| `resource_group_id` | int64 | 钱包资源组 ID。 | +| `weight` | int64 | 抽奖权重,只用于展示或调试,不参与客户端抽奖。 | +| `display_name` | string | 奖励展示名称。 | +| `icon_url` | string | 奖励图标。 | + +`state` 字段: + +| 字段 | 类型 | 说明 | +| --- | --- | --- | +| `current_level` | int32 | 当前等级,1 到 7。 | +| `current_progress` | int64 | 当前等级已累计有效能量。 | +| `energy_threshold` | int64 | 当前等级阈值。 | +| `status` | string | 当前返回 `idle` 或 `countdown`。 | +| `countdown_started_at_ms` | int64 | 倒计时开始时间;非倒计时可为空或 0。 | +| `open_at_ms` | int64 | 预计开箱时间;`status=countdown` 时有效。 | +| `opened_at_ms` | int64 | 最近一次开箱时间。 | +| `reset_at_ms` | int64 | 下一个 UTC 0 点;到点后进度按新 UTC 日重置。 | +| `top1_user_id` | string | 倒计时开始时锁定的贡献第一;GET 接口中按字符串返回。 | +| `igniter_user_id` | string | 点火人;GET 接口中按字符串返回。 | +| `box_id` | string | 当前倒计时宝箱或最近状态的单轮幂等 ID。 | +| `config_version` | int64 | 当前状态关联的后台配置版本。 | +| `last_rewards` | array | 最近一次开箱发奖结果;客户端可用于展示开箱结果兜底。 | + +`last_rewards[]` 字段: + +| 字段 | 类型 | 说明 | +| --- | --- | --- | +| `reward_role` | string | `in_room`、`top1`、`igniter`。 | +| `user_id` | string | 获奖用户 ID 字符串。 | +| `reward_item_id` | string | 奖励项 ID。 | +| `resource_group_id` | int64 | 资源组 ID。 | +| `display_name` | string | 奖励展示名称。 | +| `icon_url` | string | 奖励图标。 | +| `grant_id` | string | 钱包发放记录 ID。 | +| `status` | string | 当前成功发放为 `succeeded`。 | + +展示规则: + +- 进度条使用 `current_progress / energy_threshold`,并且最大显示 100%。 +- 倒计时使用 `open_at_ms - server_time_ms` 初始化,再用本地 timer 驱动。 +- 不要把送礼溢出能量展示到下一等级;服务端规则是溢出作废。 +- 如果 `status=countdown`,继续送礼不会改变宝箱进度、点火人、贡献第一或 `open_at_ms`。 + +## 送礼并更新宝箱 + +```http +POST /api/v1/rooms/gift/send +Authorization: Bearer +X-App-Code: lalu +Content-Type: application/json +``` + +请求体: + +```json +{ + "room_id": "lalu_xxx", + "command_id": "cmd_send_gift_lalu_xxx_12345678_1779259000000", + "target_type": "user", + "target_user_ids": [12345678], + "gift_id": "rocket", + "gift_count": 1 +} +``` + +参数: + +| 字段 | 类型 | 必填 | 说明 | +| --- | --- | --- | --- | +| `room_id` | string | 是 | 当前房间 ID。 | +| `command_id` | string | 是 | 本次送礼动作幂等键;同一次 HTTP 重试必须复用。 | +| `target_type` | string | 否 | 当前只支持 `user`;为空按 `user` 处理。 | +| `target_user_ids` | int64[] | 是 | 收礼用户列表;`target_type=user` 时必须且只能 1 个。 | +| `target_user_id` | int64 | 否 | 旧客户端兼容字段;新客户端优先用 `target_user_ids`。 | +| `gift_id` | string | 是 | 礼物 ID。 | +| `gift_count` | int32 | 是 | 礼物数量,必须大于 0。 | + +成功响应核心字段: + +```json +{ + "code": "OK", + "message": "ok", + "request_id": "req_xxx", + "data": { + "result": { + "applied": true, + "room_version": 7, + "server_time_ms": 1779259502200 + }, + "billing_receipt_id": "bill_xxx", + "room_heat": 500, + "gift_rank": [ + { + "user_id": 12345678, + "score": 500, + "gift_value": 500, + "updated_at_ms": 1779259502104 + } + ], + "room": {}, + "treasure": { + "current_level": 2, + "current_progress": 200, + "energy_threshold": 200, + "status": "countdown", + "countdown_started_at_ms": 1779259502104, + "open_at_ms": 1779259512104, + "reset_at_ms": 1779321600000, + "top1_user_id": 12345678, + "igniter_user_id": 12345678, + "box_id": "room_treasure_box_xxx", + "config_version": 3 + } + } +} +``` + +字段说明: + +| 字段 | 类型 | 说明 | +| --- | --- | --- | +| `data.result.applied` | bool | 命令是否产生新状态;同一 `command_id` 重试可能返回历史结果。 | +| `data.result.room_version` | int64 | 房间版本,用于和 IM 消息去重、排序。 | +| `data.billing_receipt_id` | string | 钱包扣费回执。 | +| `data.room_heat` | int64 | 送礼后的房间热度。 | +| `data.gift_rank` | array | 送礼后的本地贡献榜快照。 | +| `data.room` | object | 房间快照。 | +| `data.treasure` | object | 送礼后宝箱状态;送礼人可立即用它刷新 UI。 | + +注意:`GET /treasure` 的用户 ID 字段按字符串返回,`POST /gift/send` 透出的 protobuf 状态里部分用户 ID 可能是数字。Flutter 侧建议用统一 helper 兼容 `String` 和 `num`。 + +```dart +String readId(dynamic value) { + if (value == null) return ''; + if (value is String) return value; + if (value is num) return value.toInt().toString(); + return ''; +} +``` + +幂等规则: + +| 场景 | 处理 | +| --- | --- | +| HTTP 超时后重试同一次送礼 | 复用原 `command_id`。 | +| 用户点了第二次送礼按钮 | 生成新的 `command_id`。 | +| 同一个 `command_id` 换了礼物、数量或目标 | 服务端返回冲突错误。 | + +## 错误处理 + +失败响应: + +```json +{ + "code": "PERMISSION_DENIED", + "message": "permission denied", + "request_id": "req_xxx" +} +``` + +通用错误: + +| HTTP | `code` | 场景 | Flutter 处理 | +| --- | --- | --- | --- | +| 400 | `INVALID_JSON` | 请求体不是合法 JSON。 | 修正客户端请求,不重试。 | +| 400 | `INVALID_ARGUMENT` | 缺字段、数量非法、room_id 格式非法、暂不支持的 `target_type`。 | 修正参数,不自动重试。 | +| 401 | `UNAUTHORIZED` | token 缺失、过期或无效。 | 走登录刷新或重新登录。 | +| 403 | `PROFILE_REQUIRED` | 用户资料未完成。 | 跳转资料补全流程。 | +| 403 | `PERMISSION_DENIED` | 不在房间、被 ban、无权限读取或操作。 | 退出房间页或刷新房间状态。 | +| 404 | `NOT_FOUND` | 房间不存在、送礼人不在房、收礼人不在房、礼物不可用。 | 刷新房间状态;礼物不可用时刷新礼物面板。 | +| 409 | `ROOM_CLOSED` | 房间已关闭。 | 退出房间页并刷新列表。 | +| 409 | `CONFLICT` | 状态或唯一约束冲突。 | 刷新后重试。 | +| 409 | `IDEMPOTENCY_CONFLICT` | `command_id` 复用但请求内容不同。 | 新用户动作生成新 `command_id`;重试旧动作必须保持原请求体。 | +| 409 | `INSUFFICIENT_BALANCE` | 钱包余额不足。 | 弹充值/余额不足提示。 | +| 409 | `LEDGER_CONFLICT` | 钱包并发账本冲突。 | 可短延迟重试一次,仍失败则刷新余额。 | +| 502 | `UPSTREAM_ERROR` | room/wallet 等依赖暂不可用。 | 提示稍后重试,可保留原 `command_id` 重试同一次送礼。 | +| 500 | `INTERNAL_ERROR` | 服务端内部错误。 | 提示失败并记录 `request_id`。 | + +宝箱相关额外规则: + +- 获取宝箱信息要求当前用户仍在房间业务 presence 内;离房后调用会返回 `PERMISSION_DENIED` 或 `NOT_FOUND`。 +- 宝箱关闭时,`GET /treasure` 仍可能返回 `enabled=false` 和 7 级配置;客户端不展示进度入口即可。 +- 发奖自动完成,没有 App 领取接口;客户端不要补发或重复调用钱包接口。 + +## 房间群 IM 消息 + +房间内宝箱进度、倒计时、开箱和发奖通过腾讯云 IM 房间群 `TIMCustomElem` 投递。 + +| 字段 | 值 | +| --- | --- | +| `MsgType` | `TIMCustomElem` | +| `MsgContent.Ext` | `room_system_message` | +| `MsgContent.Desc` | 等于 `Data.event_type` | +| `MsgContent.Data` | JSON 字符串 | +| `CloudCustomData` | 与 `MsgContent.Data` 相同的 JSON 字符串 | + +`Data` 外层结构: + +```json +{ + "event_id": "evt_xxx", + "room_id": "lalu_xxx", + "event_type": "room_treasure_progress_changed", + "actor_user_id": 12345678, + "target_user_id": 0, + "room_version": 8, + "attributes": { + "box_id": "room_treasure_box_xxx" + } +} +``` + +通用字段: + +| 字段 | 类型 | 说明 | +| --- | --- | --- | +| `event_id` | string | IM 去重键。 | +| `room_id` | string | 房间 ID。 | +| `event_type` | string | 事件类型,见下表。 | +| `actor_user_id` | int64 | 触发用户;倒计时/开箱通常为点火人。 | +| `target_user_id` | int64 | 目标用户;倒计时/开箱通常为 top1。 | +| `room_version` | int64 | 房间版本,本地版本更高时丢弃旧消息。 | +| `attributes` | map | 业务字段;值全部按字符串传输,Flutter 侧按需转 int/bool/JSON。 | + +宝箱房间群事件: + +| `event_type` | `Desc` | 用途 | +| --- | --- | --- | +| `room_treasure_progress_changed` | `room_treasure_progress_changed` | 有效宝箱能量增加。 | +| `room_treasure_countdown_started` | `room_treasure_countdown_started` | 当前等级满能量,进入倒计时。 | +| `room_treasure_opened` | `room_treasure_opened` | 宝箱已打开,包含公开奖励摘要。 | +| `room_treasure_reward_granted` | `room_treasure_reward_granted` | 发奖结果事件,包含奖励列表。 | + +### room_treasure_progress_changed + +示例: + +```json +{ + "event_id": "evt_progress", + "room_id": "lalu_xxx", + "event_type": "room_treasure_progress_changed", + "actor_user_id": 12345678, + "room_version": 6, + "attributes": { + "box_id": "room_treasure_box_xxx", + "level": "2", + "added_energy": "300", + "effective_added_energy": "200", + "overflow_energy": "100", + "current_progress": "200", + "energy_threshold": "200", + "status": "countdown", + "reset_at_ms": "1779321600000", + "gift_id": "rocket", + "gift_count": "3", + "command_id": "cmd_send_gift_xxx" + } +} +``` + +处理: + +- 更新当前等级进度为 `current_progress`。 +- `effective_added_energy` 是本次实际计入宝箱的能量。 +- `overflow_energy` 是作废能量,不能加到下一等级。 +- 如果 `status=countdown`,立即切换倒计时态,等待 `room_treasure_countdown_started` 补齐 `open_at_ms`;如该消息丢失,调用 GET 兜底。 + +### room_treasure_countdown_started + +示例: + +```json +{ + "event_id": "evt_countdown", + "room_id": "lalu_xxx", + "event_type": "room_treasure_countdown_started", + "actor_user_id": 12345678, + "target_user_id": 12345678, + "room_version": 6, + "attributes": { + "box_id": "room_treasure_box_xxx", + "level": "2", + "current_progress": "200", + "energy_threshold": "200", + "countdown_start_ms": "1779259502104", + "open_at_ms": "1779259512104", + "broadcast_scope": "region", + "visible_region_id": "1001", + "top1_user_id": "12345678", + "igniter_user_id": "12345678", + "command_id": "cmd_send_gift_xxx" + } +} +``` + +处理: + +- 按 `open_at_ms` 展示开箱倒计时。 +- `top1_user_id` 和 `igniter_user_id` 在此刻锁定;后续离房或下线也不影响这两类奖励发放。 +- 倒计时期间再收到普通 `room_gift_sent`,只更新礼物表现和热度,不改变宝箱倒计时。 + +注意:房间群 IM 字段名是 `countdown_start_ms`,HTTP state 字段名是 `countdown_started_at_ms`。 + +### room_treasure_opened + +示例: + +```json +{ + "event_id": "evt_opened", + "room_id": "lalu_xxx", + "event_type": "room_treasure_opened", + "actor_user_id": 12345678, + "target_user_id": 12345678, + "room_version": 8, + "attributes": { + "box_id": "room_treasure_box_xxx", + "level": "2", + "next_level": "3", + "opened_at_ms": "1779259512668", + "reset_at_ms": "1779321600000", + "top1_user_id": "12345678", + "igniter_user_id": "12345678", + "rewards_json": "[{\"reward_role\":\"in_room\",\"user_id\":12345678,\"reward_item_id\":\"lv2_in_room_1\",\"resource_group_id\":1001,\"display_name\":\"Gold Pack\",\"icon_url\":\"https://cdn.example/reward/gold.png\",\"grant_id\":\"rgr_xxx\",\"status\":\"succeeded\"}]" + } +} +``` + +处理: + +- 播放当前等级 `opening_animation_url`,再展示 `opened_image_url` 或奖励弹窗。 +- 解析 `rewards_json` 得到奖励列表;如果解析失败,调用 GET 获取 `state.last_rewards`。 +- 本地状态进入 `next_level`,进度归 0,状态归 `idle`。 + +### room_treasure_reward_granted + +示例: + +```json +{ + "event_id": "evt_reward", + "room_id": "lalu_xxx", + "event_type": "room_treasure_reward_granted", + "room_version": 8, + "attributes": { + "box_id": "room_treasure_box_xxx", + "level": "2", + "rewards_json": "[{\"reward_role\":\"top1\",\"user_id\":12345678,\"reward_item_id\":\"lv2_top1_1\",\"resource_group_id\":1002,\"display_name\":\"Top Reward\",\"icon_url\":\"https://cdn.example/reward/top.png\",\"grant_id\":\"rgr_xxx\",\"status\":\"succeeded\"}]" + } +} +``` + +处理: + +- 解析 `rewards_json`。 +- 当前登录用户在奖励列表中时,展示“获得奖励”提示。 +- 同一用户可能同时命中 `in_room`、`top1`、`igniter`,是否叠加由 `reward_stack_policy` 决定;客户端按服务端发下来的列表展示。 + +当前没有单独的 C2C 宝箱中奖私信,也没有 App 消息 Tab 宝箱入箱消息。后续如新增私有通知,应以新的 C2C `Ext` 文档为准,不复用房间群消息假装私信。 + +## 区域/全局播报 IM + +满能量进入倒计时后,如果后台配置允许播报,`activity-service` 会向区域或全局播报群发送 `TIMCustomElem`。 + +| 字段 | 值 | +| --- | --- | +| `MsgType` | `TIMCustomElem` | +| `MsgContent.Ext` | `im_broadcast` | +| `MsgContent.Desc` | `room_treasure` | +| `MsgContent.Data` | JSON 字符串 | +| `CloudCustomData` | 与 `MsgContent.Data` 相同的 JSON 字符串 | + +`Data` 示例: + +```json +{ + "event_id": "room_treasure_broadcast:lalu_xxx:room_treasure_box_xxx", + "broadcast_type": "room_treasure", + "scope": "region", + "app_code": "lalu", + "region_id": 1001, + "room_id": "lalu_xxx", + "box_id": "room_treasure_box_xxx", + "level": 2, + "open_at_ms": 1779259512104, + "top1_user_id": 12345678, + "igniter_user_id": 12345678, + "sent_at_ms": 1779259502200, + "room_version": 6, + "source_event_id": "evt_countdown", + "current_progress": 200, + "energy_threshold": 200, + "countdown_started_at_ms": 1779259502104, + "action": { + "type": "enter_room", + "room_id": "lalu_xxx" + } +} +``` + +处理: + +- `broadcast_type=room_treasure` 时展示宝箱倒计时播报。 +- 点击播报按 `action.type=enter_room` 跳转或进房。 +- `scope=region` 表示区域播报群;`scope=global` 表示全局播报群。 +- 播报只负责引导和展示,不代替房间内宝箱状态;进房后仍调用 GET 初始化。 + +## 普通送礼房间 IM + +送礼成功还会产生普通房间群消息: + +| 字段 | 值 | +| --- | --- | +| `MsgContent.Ext` | `room_system_message` | +| `MsgContent.Desc` | `room_gift_sent` | +| `Data.event_type` | `room_gift_sent` | + +宝箱 UI 不应该只靠 `room_gift_sent` 推算能量;能量以 `room_treasure_progress_changed`、`room_treasure_countdown_started`、`POST /gift/send.data.treasure` 或 GET 结果为准。 + +## Flutter 解析建议 + +IM `attributes` 的值都是字符串,建议统一封装解析: + +```dart +int readInt(dynamic value, [int fallback = 0]) { + if (value == null) return fallback; + if (value is num) return value.toInt(); + if (value is String) return int.tryParse(value) ?? fallback; + return fallback; +} + +String readString(dynamic value) { + if (value == null) return ''; + if (value is String) return value; + if (value is num) return value.toInt().toString(); + return value.toString(); +} + +List> readRewardsJson(dynamic value) { + final raw = readString(value); + if (raw.isEmpty) return const []; + final decoded = jsonDecode(raw); + if (decoded is! List) return const []; + return decoded.whereType().map((item) { + return item.map((key, val) => MapEntry(key.toString(), val)); + }).toList(); +} +``` + +推荐本地状态更新策略: + +| 输入 | 处理 | +| --- | --- | +| `GET /treasure` | 覆盖本地完整宝箱状态和 7 级物料。 | +| `POST /gift/send.data.treasure` | 送礼人立即刷新当前状态。 | +| `room_treasure_progress_changed` | 同房间、版本不旧时更新进度。 | +| `room_treasure_countdown_started` | 切换倒计时,并锁定 top1/点火人展示。 | +| `room_treasure_opened` | 播放开箱动效,状态切到下一等级 idle。 | +| `room_treasure_reward_granted` | 展示当前用户中奖提示;按 `event_id` 去重。 | +| `im_broadcast + room_treasure` | 展示区域/全局播报入口,不直接改当前房间状态。 | + +如果客户端发现本地状态和服务端事件无法合并,例如当前 `box_id` 不一致、`level` 倒退、`room_version` 缺失,直接调用 `GET /api/v1/rooms/{room_id}/treasure` 重新同步。 diff --git a/docs/幸运礼物线上落地文档.md b/docs/幸运礼物线上落地文档.md index 0b5ed815..b7bf6d29 100644 --- a/docs/幸运礼物线上落地文档.md +++ b/docs/幸运礼物线上落地文档.md @@ -10,8 +10,8 @@ | --- | --- | --- | | `LuckyGiftService.CheckLuckyGift` | done | 房间送礼前检查礼物是否启用,并返回当前体验池 | | `LuckyGiftService.ExecuteLuckyGiftDraw` | done | 扣费成功后执行抽奖决策,写抽奖事实和 outbox | -| `AdminLuckyGiftService.GetLuckyGiftConfig` | done | 后台读取全局幸运礼物规则;未配置时返回默认草稿 | -| `AdminLuckyGiftService.UpsertLuckyGiftConfig` | done | 后台发布全局幸运礼物规则版本 | +| `AdminLuckyGiftService.GetLuckyGiftConfig` | done | 后台按 `pool_id` 读取幸运礼物奖池规则;未配置时返回默认草稿 | +| `AdminLuckyGiftService.UpsertLuckyGiftConfig` | done | 后台按 `pool_id` 发布独立幸运礼物奖池规则版本 | | `AdminLuckyGiftService.ListLuckyGiftDraws` | done | 后台审计抽奖记录 | | 三层基础奖池 | done | platform / room / gift | | RTP 双窗口 | done | global + gift | @@ -25,23 +25,23 @@ ```text room-service SendGift - 1. CheckLuckyGift(meta, user_id, room_id, gift_id) + 1. CheckLuckyGift(meta, user_id, room_id, gift_id, pool_id) 2. wallet-service 扣费成功 - 3. ExecuteLuckyGiftDraw(command_id, user_id, device_id, room_id, anchor_id, gift_id, coin_spent, paid_at_ms) + 3. ExecuteLuckyGiftDraw(command_id, user_id, device_id, room_id, anchor_id, gift_id, pool_id, coin_spent, paid_at_ms) 4. activity-service 本地事务写 draw fact + outbox 5. 事务后由钱包/房间表现链路处理 reward_status=pending 的结果 ``` `command_id` 是抽奖幂等键。重复 `ExecuteLuckyGiftDraw` 会返回同一条 `lucky_draw_records`,不会重复消耗 RTP、奖池或预算。 -后台只维护一份全局幸运礼物规则,实际抽奖仍记录用户发送的真实 `gift_id`。后台不暴露单抽消耗;服务端保存固定内部换算单位,不同幸运礼物实际消耗不同,抽奖事务按实际 `coin_spent` 等比缩放基础奖档,并按实际 `coin_spent` 累计 RTP 目标和奖池入账。 +后台按 `pool_id` 维护多份互相独立的幸运礼物奖池规则,实际抽奖仍记录用户发送的真实 `gift_id`。后台不暴露单抽消耗;服务端保存固定内部换算单位,不同幸运礼物实际消耗不同,抽奖事务按实际 `coin_spent` 等比缩放基础奖档,并按实际 `coin_spent` 累计 RTP 目标和奖池入账。同一个 `gift_id` 可以请求不同 `pool_id`,不同 `pool_id` 的 RTP 窗口、基础奖池、房间气氛池、活动预算、用户阶段和风控计数互不共享。 ## Transaction Boundary `ExecuteLuckyGiftDraw` 使用一个 MySQL 本地事务,事务内必须完成: 1. 按 `command_id` 锁定幂等记录。 -2. 锁定 `lucky_gift_rules` 全局规则。 +2. 锁定 `lucky_gift_rules` 中 `pool_id` 对应规则。 3. 锁定或创建用户体验状态 `lucky_user_states`。 4. 锁定或创建全站 RTP 窗口、幸运礼物整体子窗口。 5. 锁定或创建平台、房间、幸运礼物整体三层基础奖池。 @@ -82,16 +82,17 @@ effective_reward_coins = base_reward_coins | Method | Path | Permission | Meaning | | --- | --- | --- | --- | -| `GET` | `/api/v1/admin/activity/lucky-gifts/config` | `lucky-gift:view` | 读取全局幸运礼物规则;未配置返回默认草稿 | -| `PUT` | `/api/v1/admin/activity/lucky-gifts/config` | `lucky-gift:update` | 发布新的全局幸运礼物规则版本 | +| `GET` | `/api/v1/admin/activity/lucky-gifts/config?pool_id=pool_95` | `lucky-gift:view` | 读取指定奖池规则;未配置返回默认草稿 | +| `PUT` | `/api/v1/admin/activity/lucky-gifts/config?pool_id=pool_95` | `lucky-gift:update` | 发布指定奖池的新规则版本 | | `GET` | `/api/v1/admin/activity/lucky-gifts/draws` | `lucky-gift:view` | 查询抽奖审计记录 | | Group | Fields | Meaning | | --- | --- | --- | | RTP 成本 | `target_rtp_ppm`, `pool_rate_ppm`, `global_window_draws`, `gift_window_draws` | 控制基础 RTP 和收敛窗口 | | 三层池 | `platform_pool_weight_ppm`, `room_pool_weight_ppm`, `gift_pool_weight_ppm`, `initial_*_pool`, `*_reserve` | 控制平台、房间、礼物各自承担的基础返奖能力 | -| 体验池 | `novice_draw_limit`, `intermediate_draw_limit`, `tiers` | 控制新手/中级/高级奖档结构 | -| 高倍 | `high_multiplier`, `high_water_pool_multiple`, `large_tier_enabled`, tier `high_water_only` | 控制 100x/500x 等高倍是否可进入候选 | +| 倍率配置 | `multiplier_ppms`, generated `tiers` | 运营只配置用户可能命中的倍率;服务按 RTP 窗口、水位和风控自动生成实时概率 | +| 体验池 | `novice_draw_limit`, `intermediate_draw_limit` | 控制新手/中级/高级阶段,不直接决定成本 | +| 高倍 | `high_multiplier`, `high_water_pool_multiple`, `large_tier_enabled` | 控制 100x/500x 等高倍倍率是否可进入候选 | | 风控 | `max_single_payout`, `user_hourly_payout_cap`, `user_daily_payout_cap`, `device_daily_payout_cap`, `room_hourly_payout_cap`, `anchor_daily_payout_cap` | 控制单用户、设备、房间、主播关联风险敞口 | | 娱乐补贴 | `room_atmosphere_rate_ppm`, `room_atmosphere_initial`, `room_atmosphere_reserve`, `activity_budget`, `activity_daily_limit` | 控制房间爆点和活动补贴 | @@ -100,8 +101,8 @@ effective_reward_coins = base_reward_coins | Goal | Primary Change | | --- | --- | | 降成本 | 降 `target_rtp_ppm`,降补贴预算,提高高水位倍数 | -| 增刺激 | 增房间气氛和活动预算,提高中高倍权重 | -| 新手更友好 | 提高新手小奖权重,延长 `novice_draw_limit` | +| 增刺激 | 增加高倍倍率,增房间气氛和活动预算 | +| 新手更友好 | 增加 0.2x/0.5x/1x 等小倍率,延长 `novice_draw_limit` | | 防套利 | 降低用户/设备/房间/主播上限,关闭高倍 | | RTP 更稳 | 缩小 RTP 窗口 | | 体验更自然 | 放大 RTP 窗口,但接受短周期波动 | @@ -110,13 +111,13 @@ effective_reward_coins = base_reward_coins | Table | Owner Data | | --- | --- | -| `lucky_gift_rules` | 全局幸运礼物规则和后台可调参数;规则作用域固定为 `__global__` | -| `lucky_rtp_windows` | 全站/幸运礼物整体 RTP 窗口 | -| `lucky_pools` | platform / room / lucky-gift-global 三层基础奖池 | -| `lucky_room_atmosphere_pools` | 房间气氛预算 | -| `lucky_activity_budgets` | 活动补贴预算;`budget_day=__total__` 控总额,UTC 日期行控日释放 | -| `lucky_user_states` | 用户在幸运礼物整体上的累计付费抽数 | -| `lucky_risk_counters` | 风控窗口计数 | +| `lucky_gift_rules` | 按 `pool_id` 保存幸运礼物奖池规则和后台可调参数 | +| `lucky_rtp_windows` | 按 `pool_id` 保存 RTP 控制窗口 | +| `lucky_pools` | 按 `pool_id` 保存 platform / room / gift 三层基础奖池 | +| `lucky_room_atmosphere_pools` | 按 `pool_id + room_id` 保存房间气氛预算 | +| `lucky_activity_budgets` | 按 `pool_id` 保存活动补贴预算;`budget_day=__total__` 控总额,UTC 日期行控日释放 | +| `lucky_user_states` | 用户在单个 `pool_id` 内的累计付费抽数 | +| `lucky_risk_counters` | 按 `pool_id` 保存风控窗口计数 | | `lucky_draw_records` | 每次抽奖事实和审计快照 | ## Verification diff --git a/server/admin/internal/modules/gamemanagement/dto.go b/server/admin/internal/modules/gamemanagement/dto.go index 458441d6..f940f4a8 100644 --- a/server/admin/internal/modules/gamemanagement/dto.go +++ b/server/admin/internal/modules/gamemanagement/dto.go @@ -3,14 +3,18 @@ package gamemanagement import gamev1 "hyapp.local/api/proto/game/v1" type platformDTO struct { - AppCode string `json:"appCode"` - PlatformCode string `json:"platformCode"` - PlatformName string `json:"platformName"` - Status string `json:"status"` - APIBaseURL string `json:"apiBaseUrl"` - SortOrder int32 `json:"sortOrder"` - CreatedAtMS int64 `json:"createdAtMs"` - UpdatedAtMS int64 `json:"updatedAtMs"` + AppCode string `json:"appCode"` + PlatformCode string `json:"platformCode"` + PlatformName string `json:"platformName"` + Status string `json:"status"` + APIBaseURL string `json:"apiBaseUrl"` + AdapterType string `json:"adapterType"` + CallbackSecretSet bool `json:"callbackSecretSet"` + CallbackIPWhitelist []string `json:"callbackIpWhitelist"` + AdapterConfigJSON string `json:"adapterConfigJson"` + SortOrder int32 `json:"sortOrder"` + CreatedAtMS int64 `json:"createdAtMs"` + UpdatedAtMS int64 `json:"updatedAtMs"` } type catalogDTO struct { @@ -37,14 +41,18 @@ func platformFromProto(item *gamev1.GamePlatform) platformDTO { return platformDTO{} } return platformDTO{ - AppCode: item.GetAppCode(), - PlatformCode: item.GetPlatformCode(), - PlatformName: item.GetPlatformName(), - Status: item.GetStatus(), - APIBaseURL: item.GetApiBaseUrl(), - SortOrder: item.GetSortOrder(), - CreatedAtMS: item.GetCreatedAtMs(), - UpdatedAtMS: item.GetUpdatedAtMs(), + AppCode: item.GetAppCode(), + PlatformCode: item.GetPlatformCode(), + PlatformName: item.GetPlatformName(), + Status: item.GetStatus(), + APIBaseURL: item.GetApiBaseUrl(), + AdapterType: item.GetAdapterType(), + CallbackSecretSet: item.GetCallbackSecretSet(), + CallbackIPWhitelist: item.GetCallbackIpWhitelist(), + AdapterConfigJSON: item.GetAdapterConfigJson(), + SortOrder: item.GetSortOrder(), + CreatedAtMS: item.GetCreatedAtMs(), + UpdatedAtMS: item.GetUpdatedAtMs(), } } diff --git a/server/admin/internal/modules/gamemanagement/request.go b/server/admin/internal/modules/gamemanagement/request.go index 6446c975..47d61232 100644 --- a/server/admin/internal/modules/gamemanagement/request.go +++ b/server/admin/internal/modules/gamemanagement/request.go @@ -12,11 +12,15 @@ import ( ) type platformRequest struct { - PlatformCode string `json:"platformCode"` - PlatformName string `json:"platformName"` - Status string `json:"status"` - APIBaseURL string `json:"apiBaseUrl"` - SortOrder int32 `json:"sortOrder"` + PlatformCode string `json:"platformCode"` + PlatformName string `json:"platformName"` + Status string `json:"status"` + APIBaseURL string `json:"apiBaseUrl"` + AdapterType string `json:"adapterType"` + CallbackSecret string `json:"callbackSecret"` + CallbackIPWhitelist []string `json:"callbackIpWhitelist"` + AdapterConfigJSON string `json:"adapterConfigJson"` + SortOrder int32 `json:"sortOrder"` } type catalogRequest struct { @@ -44,11 +48,15 @@ func (r platformRequest) toProto(platformCode string) *gamev1.GamePlatform { platformCode = r.PlatformCode } return &gamev1.GamePlatform{ - PlatformCode: strings.TrimSpace(platformCode), - PlatformName: strings.TrimSpace(r.PlatformName), - Status: strings.TrimSpace(r.Status), - ApiBaseUrl: strings.TrimSpace(r.APIBaseURL), - SortOrder: r.SortOrder, + PlatformCode: strings.TrimSpace(platformCode), + PlatformName: strings.TrimSpace(r.PlatformName), + Status: strings.TrimSpace(r.Status), + ApiBaseUrl: strings.TrimSpace(r.APIBaseURL), + AdapterType: strings.TrimSpace(r.AdapterType), + CallbackSecret: strings.TrimSpace(r.CallbackSecret), + CallbackIpWhitelist: compactTags(r.CallbackIPWhitelist), + AdapterConfigJson: strings.TrimSpace(r.AdapterConfigJSON), + SortOrder: r.SortOrder, } } diff --git a/server/admin/internal/modules/luckygift/handler.go b/server/admin/internal/modules/luckygift/handler.go index 492482eb..651f13de 100644 --- a/server/admin/internal/modules/luckygift/handler.go +++ b/server/admin/internal/modules/luckygift/handler.go @@ -24,6 +24,7 @@ func New(activity activityclient.Client, audit shared.OperationLogger) *Handler } type configRequest struct { + PoolID string `json:"pool_id"` Enabled bool `json:"enabled"` GiftPrice int64 `json:"gift_price"` TargetRTPPPM int64 `json:"target_rtp_ppm"` @@ -55,6 +56,7 @@ type configRequest struct { ActivityBudget int64 `json:"activity_budget"` ActivityDailyLimit int64 `json:"activity_daily_limit"` LargeTierEnabled bool `json:"large_tier_enabled"` + MultiplierPPMs []int64 `json:"multiplier_ppms"` Tiers []tierDTO `json:"tiers"` } @@ -72,6 +74,7 @@ type tierDTO struct { Pool string `json:"pool"` TierID string `json:"tier_id"` RewardCoins int64 `json:"reward_coins"` + MultiplierPPM int64 `json:"multiplier_ppm"` Weight int64 `json:"weight"` HighWaterOnly bool `json:"high_water_only"` Enabled bool `json:"enabled"` @@ -80,6 +83,7 @@ type tierDTO struct { type drawDTO struct { DrawID string `json:"draw_id"` CommandID string `json:"command_id"` + PoolID string `json:"pool_id"` GiftID string `json:"gift_id"` RuleVersion int64 `json:"rule_version"` ExperiencePool string `json:"experience_pool"` @@ -101,7 +105,8 @@ type drawDTO struct { func (h *Handler) GetLuckyGiftConfig(c *gin.Context) { resp, err := h.activity.GetLuckyGiftConfig(c.Request.Context(), &activityv1.GetLuckyGiftConfigRequest{ - Meta: h.meta(c), + Meta: h.meta(c), + PoolId: strings.TrimSpace(c.Query("pool_id")), }) if err != nil { response.ServerError(c, "获取幸运礼物配置失败") @@ -116,6 +121,9 @@ func (h *Handler) UpsertLuckyGiftConfig(c *gin.Context) { response.BadRequest(c, "幸运礼物配置参数不正确") return } + if req.PoolID == "" { + req.PoolID = strings.TrimSpace(c.Query("pool_id")) + } resp, err := h.activity.UpsertLuckyGiftConfig(c.Request.Context(), &activityv1.UpsertLuckyGiftConfigRequest{ Meta: h.meta(c), Config: configToProto(req), @@ -126,7 +134,7 @@ func (h *Handler) UpsertLuckyGiftConfig(c *gin.Context) { return } item := configFromProto(resp.GetConfig()) - shared.OperationLogWithResourceID(c, h.audit, "upsert-lucky-gift-config", "lucky_gift_rules", "global", "success", "") + shared.OperationLogWithResourceID(c, h.audit, "upsert-lucky-gift-config", "lucky_gift_rules", item.PoolID, "success", "") response.OK(c, item) } @@ -134,6 +142,7 @@ func (h *Handler) ListLuckyGiftDraws(c *gin.Context) { options := shared.ListOptions(c) resp, err := h.activity.ListLuckyGiftDraws(c.Request.Context(), &activityv1.ListLuckyGiftDrawsRequest{ Meta: h.meta(c), + PoolId: strings.TrimSpace(c.Query("pool_id")), GiftId: strings.TrimSpace(c.Query("gift_id")), UserId: parseOptionalInt64(c.Query("user_id")), RoomId: strings.TrimSpace(c.Query("room_id")), @@ -168,12 +177,14 @@ func configToProto(req configRequest) *activityv1.LuckyGiftConfig { Pool: tier.Pool, TierId: tier.TierID, RewardCoins: tier.RewardCoins, + MultiplierPpm: tier.MultiplierPPM, Weight: tier.Weight, HighWaterOnly: tier.HighWaterOnly, Enabled: tier.Enabled, }) } return &activityv1.LuckyGiftConfig{ + PoolId: strings.TrimSpace(req.PoolID), Enabled: req.Enabled, GiftPrice: req.GiftPrice, TargetRtpPpm: req.TargetRTPPPM, @@ -205,6 +216,7 @@ func configToProto(req configRequest) *activityv1.LuckyGiftConfig { ActivityBudget: req.ActivityBudget, ActivityDailyLimit: req.ActivityDailyLimit, LargeTierEnabled: req.LargeTierEnabled, + MultiplierPpms: append([]int64(nil), req.MultiplierPPMs...), Tiers: tiers, } } @@ -219,11 +231,16 @@ func configFromProto(config *activityv1.LuckyGiftConfig) configDTO { Pool: tier.GetPool(), TierID: tier.GetTierId(), RewardCoins: tier.GetRewardCoins(), + MultiplierPPM: tier.GetMultiplierPpm(), Weight: tier.GetWeight(), HighWaterOnly: tier.GetHighWaterOnly(), Enabled: tier.GetEnabled(), }) } + poolID := strings.TrimSpace(config.GetPoolId()) + if poolID == "" { + poolID = strings.TrimSpace(config.GetGiftId()) + } return configDTO{ AppCode: config.GetAppCode(), GiftID: config.GetGiftId(), @@ -232,6 +249,7 @@ func configFromProto(config *activityv1.LuckyGiftConfig) configDTO { CreatedAtMS: config.GetCreatedAtMs(), UpdatedAtMS: config.GetUpdatedAtMs(), configRequest: configRequest{ + PoolID: poolID, Enabled: config.GetEnabled(), GiftPrice: config.GetGiftPrice(), TargetRTPPPM: config.GetTargetRtpPpm(), @@ -263,6 +281,7 @@ func configFromProto(config *activityv1.LuckyGiftConfig) configDTO { ActivityBudget: config.GetActivityBudget(), ActivityDailyLimit: config.GetActivityDailyLimit(), LargeTierEnabled: config.GetLargeTierEnabled(), + MultiplierPPMs: append([]int64(nil), config.GetMultiplierPpms()...), Tiers: tiers, }, } @@ -275,6 +294,7 @@ func drawFromProto(draw *activityv1.LuckyGiftDrawResult) drawDTO { return drawDTO{ DrawID: draw.GetDrawId(), CommandID: draw.GetCommandId(), + PoolID: draw.GetPoolId(), GiftID: draw.GetGiftId(), RuleVersion: draw.GetRuleVersion(), ExperiencePool: draw.GetExperiencePool(), diff --git a/server/admin/internal/repository/seed.go b/server/admin/internal/repository/seed.go index c27806b7..2b791122 100644 --- a/server/admin/internal/repository/seed.go +++ b/server/admin/internal/repository/seed.go @@ -128,7 +128,7 @@ var deprecatedPermissionCodes = []string{ "service:view", "service:create", "service:update", "service:operate", "notification:view", "notification:read", "notification:read-all", "notification:delete", } -var deprecatedMenuCodes = []string{"services", "notifications", "jobs", "room-treasure"} +var deprecatedMenuCodes = []string{"services", "notifications", "jobs"} func (s *Store) seedPermissions() error { return s.syncDefaultPermissions(s.db) @@ -232,6 +232,7 @@ func (s *Store) seedMenus() error { {ParentID: &activityID, Title: "注册奖励", Code: "registration-reward", Path: "/activities/registration-reward", Icon: "gift", PermissionCode: "registration-reward:view", Sort: 70, Visible: true}, {ParentID: &activityID, Title: "成就配置", Code: "achievement-config", Path: "/activities/achievements", Icon: "military_tech", PermissionCode: "achievement:view", Sort: 71, Visible: true}, {ParentID: &activityID, Title: "七日签到", Code: "seven-day-checkin", Path: "/activities/seven-day-checkin", Icon: "event_available", PermissionCode: "seven-day-checkin:view", Sort: 72, Visible: true}, + {ParentID: &activityID, Title: "房间宝箱", Code: "room-treasure", Path: "/activities/room-treasure", Icon: "redeem", PermissionCode: "room-treasure:view", Sort: 73, Visible: true}, {ParentID: &gameID, Title: "游戏列表", Code: "game-list", Path: "/games", Icon: "sports_esports", PermissionCode: "game:view", Sort: 70, Visible: true}, {ParentID: &geoID, Title: "国家管理", Code: "host-org-countries", Path: "/host/countries", Icon: "public", PermissionCode: "country:view", Sort: 70, Visible: true}, {ParentID: &geoID, Title: "区域管理", Code: "host-org-regions", Path: "/host/regions", Icon: "map", PermissionCode: "region:view", Sort: 71, Visible: true}, diff --git a/server/admin/migrations/019_remove_hidden_navigation_menus.sql b/server/admin/migrations/019_remove_hidden_navigation_menus.sql index c7288bd7..098ad522 100644 --- a/server/admin/migrations/019_remove_hidden_navigation_menus.sql +++ b/server/admin/migrations/019_remove_hidden_navigation_menus.sql @@ -1,6 +1,6 @@ SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci; --- 这两个入口不再作为后台导航展示;权限和接口保留,避免影响已有内部能力。 +-- 任务入口不再作为后台导航展示;权限和接口保留,避免影响已有内部能力。 DELETE FROM admin_menus -WHERE code IN ('jobs', 'room-treasure') - OR path IN ('/jobs', '/activities/room-treasure'); +WHERE code = 'jobs' + OR path = '/jobs'; diff --git a/services/activity-service/deploy/mysql/initdb/001_activity_service.sql b/services/activity-service/deploy/mysql/initdb/001_activity_service.sql index f3f758a4..bd2b77b7 100644 --- a/services/activity-service/deploy/mysql/initdb/001_activity_service.sql +++ b/services/activity-service/deploy/mysql/initdb/001_activity_service.sql @@ -46,7 +46,7 @@ CREATE TABLE IF NOT EXISTS activity_outbox ( CREATE TABLE IF NOT EXISTS lucky_gift_rules ( app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码', - gift_id VARCHAR(96) NOT NULL COMMENT '规则作用域,固定为 __global__', + gift_id VARCHAR(96) NOT NULL COMMENT '奖池 ID;当前列名沿用旧内部作用域名', enabled TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否启用', rule_version BIGINT NOT NULL COMMENT '规则版本', gift_price BIGINT NOT NULL COMMENT '单次抽奖消耗金币', @@ -79,7 +79,7 @@ CREATE TABLE IF NOT EXISTS lucky_gift_rules ( activity_budget BIGINT NOT NULL COMMENT '活动补贴预算', activity_daily_limit BIGINT NOT NULL COMMENT '活动补贴日上限', large_tier_enabled TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否开启高倍奖档', - tiers_json JSON NOT NULL COMMENT '体验池奖档权重快照', + tiers_json JSON NOT NULL COMMENT '倍率生成奖档快照;权重由 RTP 窗口运行时生成', updated_by_admin_id BIGINT NOT NULL DEFAULT 0 COMMENT '最后更新管理员', created_at_ms BIGINT NOT NULL COMMENT '创建时间,UTC epoch ms', updated_at_ms BIGINT NOT NULL COMMENT '更新时间,UTC epoch ms', @@ -133,7 +133,7 @@ CREATE TABLE IF NOT EXISTS lucky_room_atmosphere_pools ( CREATE TABLE IF NOT EXISTS lucky_activity_budgets ( app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码', - gift_id VARCHAR(96) NOT NULL COMMENT '预算作用域,固定为 __global__', + gift_id VARCHAR(96) NOT NULL COMMENT '奖池 ID;当前列名沿用旧内部作用域名', budget_day VARCHAR(32) NOT NULL COMMENT 'UTC 预算日;__total__ 表示活动总预算', budget_coins BIGINT NOT NULL DEFAULT 0 COMMENT '总补贴预算', spent_coins BIGINT NOT NULL DEFAULT 0 COMMENT '已支出补贴', @@ -148,7 +148,7 @@ CREATE TABLE IF NOT EXISTS lucky_activity_budgets ( CREATE TABLE IF NOT EXISTS lucky_user_states ( app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码', user_id BIGINT NOT NULL COMMENT '用户 ID', - gift_id VARCHAR(96) NOT NULL COMMENT '体验池作用域,固定为 __global__', + gift_id VARCHAR(96) NOT NULL COMMENT '奖池 ID;当前列名沿用旧内部作用域名', paid_draws BIGINT NOT NULL DEFAULT 0 COMMENT '该用户幸运礼物累计付费抽数', created_at_ms BIGINT NOT NULL COMMENT '创建时间,UTC epoch ms', updated_at_ms BIGINT NOT NULL COMMENT '更新时间,UTC epoch ms', @@ -175,6 +175,7 @@ CREATE TABLE IF NOT EXISTS lucky_draw_records ( device_id VARCHAR(128) NOT NULL COMMENT '设备 ID', room_id VARCHAR(96) NOT NULL COMMENT '房间 ID', anchor_id VARCHAR(96) NOT NULL COMMENT '主播关联 ID', + pool_id VARCHAR(96) NOT NULL COMMENT '幸运礼物奖池 ID', gift_id VARCHAR(96) NOT NULL COMMENT '礼物 ID', coin_spent BIGINT NOT NULL COMMENT '消耗金币', rule_version BIGINT NOT NULL COMMENT '规则版本', @@ -198,6 +199,7 @@ CREATE TABLE IF NOT EXISTS lucky_draw_records ( updated_at_ms BIGINT NOT NULL COMMENT '更新时间,UTC epoch ms', PRIMARY KEY (app_code, draw_id), UNIQUE KEY uk_lucky_draw_command (app_code, command_id), + KEY idx_lucky_draw_pool (app_code, pool_id, created_at_ms), KEY idx_lucky_draw_user (app_code, user_id, created_at_ms), KEY idx_lucky_draw_gift (app_code, gift_id, created_at_ms), KEY idx_lucky_draw_room (app_code, room_id, created_at_ms), diff --git a/services/activity-service/internal/domain/luckygift/lucky_gift.go b/services/activity-service/internal/domain/luckygift/lucky_gift.go index 0d102066..d730501a 100644 --- a/services/activity-service/internal/domain/luckygift/lucky_gift.go +++ b/services/activity-service/internal/domain/luckygift/lucky_gift.go @@ -1,7 +1,7 @@ package luckygift const ( - GlobalConfigGiftID = "__global__" + DefaultPoolID = "default" StatusDraft = "draft" StatusActive = "active" @@ -24,6 +24,7 @@ type Tier struct { Pool string `json:"pool"` TierID string `json:"tier_id"` RewardCoins int64 `json:"reward_coins"` + MultiplierPPM int64 `json:"multiplier_ppm"` Weight int64 `json:"weight"` HighWaterOnly bool `json:"high_water_only"` Enabled bool `json:"enabled"` @@ -31,47 +32,50 @@ type Tier struct { // Config 是幸运礼物线上规则快照。金额均为整数金币,比例均为 ppm。 type Config struct { - AppCode string `json:"app_code"` - GiftID string `json:"gift_id"` - Enabled bool `json:"enabled"` - RuleVersion int64 `json:"rule_version"` - GiftPrice int64 `json:"gift_price"` - TargetRTPPPM int64 `json:"target_rtp_ppm"` - PoolRatePPM int64 `json:"pool_rate_ppm"` - GlobalWindowDraws int64 `json:"global_window_draws"` - GiftWindowDraws int64 `json:"gift_window_draws"` - NoviceDrawLimit int64 `json:"novice_draw_limit"` - IntermediateDrawLimit int64 `json:"intermediate_draw_limit"` - HighMultiplier int64 `json:"high_multiplier"` - HighWaterPoolMultiple int64 `json:"high_water_pool_multiple"` - PlatformPoolWeightPPM int64 `json:"platform_pool_weight_ppm"` - RoomPoolWeightPPM int64 `json:"room_pool_weight_ppm"` - GiftPoolWeightPPM int64 `json:"gift_pool_weight_ppm"` - InitialPlatformPool int64 `json:"initial_platform_pool"` - InitialGiftPool int64 `json:"initial_gift_pool"` - InitialRoomPool int64 `json:"initial_room_pool"` - PlatformReserve int64 `json:"platform_reserve"` - GiftReserve int64 `json:"gift_reserve"` - RoomReserve int64 `json:"room_reserve"` - MaxSinglePayout int64 `json:"max_single_payout"` - UserHourlyPayoutCap int64 `json:"user_hourly_payout_cap"` - UserDailyPayoutCap int64 `json:"user_daily_payout_cap"` - DeviceDailyPayoutCap int64 `json:"device_daily_payout_cap"` - RoomHourlyPayoutCap int64 `json:"room_hourly_payout_cap"` - AnchorDailyPayoutCap int64 `json:"anchor_daily_payout_cap"` - RoomAtmosphereRatePPM int64 `json:"room_atmosphere_rate_ppm"` - RoomAtmosphereInitial int64 `json:"room_atmosphere_initial"` - RoomAtmosphereReserve int64 `json:"room_atmosphere_reserve"` - ActivityBudget int64 `json:"activity_budget"` - ActivityDailyLimit int64 `json:"activity_daily_limit"` - LargeTierEnabled bool `json:"large_tier_enabled"` - Tiers []Tier `json:"tiers"` - UpdatedByAdminID int64 `json:"updated_by_admin_id"` - CreatedAtMS int64 `json:"created_at_ms"` - UpdatedAtMS int64 `json:"updated_at_ms"` + AppCode string `json:"app_code"` + GiftID string `json:"gift_id"` + PoolID string `json:"pool_id"` + Enabled bool `json:"enabled"` + RuleVersion int64 `json:"rule_version"` + GiftPrice int64 `json:"gift_price"` + TargetRTPPPM int64 `json:"target_rtp_ppm"` + PoolRatePPM int64 `json:"pool_rate_ppm"` + GlobalWindowDraws int64 `json:"global_window_draws"` + GiftWindowDraws int64 `json:"gift_window_draws"` + NoviceDrawLimit int64 `json:"novice_draw_limit"` + IntermediateDrawLimit int64 `json:"intermediate_draw_limit"` + HighMultiplier int64 `json:"high_multiplier"` + HighWaterPoolMultiple int64 `json:"high_water_pool_multiple"` + PlatformPoolWeightPPM int64 `json:"platform_pool_weight_ppm"` + RoomPoolWeightPPM int64 `json:"room_pool_weight_ppm"` + GiftPoolWeightPPM int64 `json:"gift_pool_weight_ppm"` + InitialPlatformPool int64 `json:"initial_platform_pool"` + InitialGiftPool int64 `json:"initial_gift_pool"` + InitialRoomPool int64 `json:"initial_room_pool"` + PlatformReserve int64 `json:"platform_reserve"` + GiftReserve int64 `json:"gift_reserve"` + RoomReserve int64 `json:"room_reserve"` + MaxSinglePayout int64 `json:"max_single_payout"` + UserHourlyPayoutCap int64 `json:"user_hourly_payout_cap"` + UserDailyPayoutCap int64 `json:"user_daily_payout_cap"` + DeviceDailyPayoutCap int64 `json:"device_daily_payout_cap"` + RoomHourlyPayoutCap int64 `json:"room_hourly_payout_cap"` + AnchorDailyPayoutCap int64 `json:"anchor_daily_payout_cap"` + RoomAtmosphereRatePPM int64 `json:"room_atmosphere_rate_ppm"` + RoomAtmosphereInitial int64 `json:"room_atmosphere_initial"` + RoomAtmosphereReserve int64 `json:"room_atmosphere_reserve"` + ActivityBudget int64 `json:"activity_budget"` + ActivityDailyLimit int64 `json:"activity_daily_limit"` + LargeTierEnabled bool `json:"large_tier_enabled"` + MultiplierPPMs []int64 `json:"multiplier_ppms"` + Tiers []Tier `json:"tiers"` + UpdatedByAdminID int64 `json:"updated_by_admin_id"` + CreatedAtMS int64 `json:"created_at_ms"` + UpdatedAtMS int64 `json:"updated_at_ms"` } type CheckCommand struct { + PoolID string GiftID string UserID int64 RoomID string @@ -79,6 +83,7 @@ type CheckCommand struct { type DrawCommand struct { CommandID string + PoolID string UserID int64 DeviceID string RoomID string @@ -91,6 +96,7 @@ type DrawCommand struct { type CheckResult struct { Enabled bool Reason string + PoolID string GiftID string GiftPrice int64 RuleVersion int64 @@ -101,6 +107,7 @@ type CheckResult struct { type DrawResult struct { DrawID string CommandID string + PoolID string GiftID string RuleVersion int64 ExperiencePool string @@ -121,10 +128,11 @@ type DrawResult struct { } type AdminConfigQuery struct { - GiftID string + PoolID string } type DrawQuery struct { + PoolID string GiftID string UserID int64 RoomID string diff --git a/services/activity-service/internal/service/luckygift/config.go b/services/activity-service/internal/service/luckygift/config.go index 815b356e..40983c91 100644 --- a/services/activity-service/internal/service/luckygift/config.go +++ b/services/activity-service/internal/service/luckygift/config.go @@ -2,6 +2,7 @@ package luckygift import ( "fmt" + "sort" "strings" "hyapp/pkg/xerr" @@ -10,12 +11,14 @@ import ( const ppmScale int64 = 1_000_000 -// DefaultConfig 给后台首屏提供整体规则草稿;真正生效必须由后台显式 enabled。 -func DefaultConfig(appCode string) domain.Config { +// DefaultConfig 给后台指定奖池提供规则草稿;真正生效必须由后台显式 enabled。 +func DefaultConfig(appCode, poolID string) domain.Config { const giftPrice int64 = 10_000 + poolID = normalizePoolID(poolID) return domain.Config{ AppCode: appCode, - GiftID: domain.GlobalConfigGiftID, + GiftID: poolID, + PoolID: poolID, Enabled: false, GiftPrice: giftPrice, TargetRTPPPM: 950_000, @@ -47,14 +50,16 @@ func DefaultConfig(appCode string) domain.Config { ActivityBudget: 0, ActivityDailyLimit: 0, LargeTierEnabled: true, + MultiplierPPMs: defaultMultiplierPPMs(), Tiers: defaultTiers(giftPrice), } } func validateConfig(config domain.Config) error { - if config.GiftID != domain.GlobalConfigGiftID { - return xerr.New(xerr.InvalidArgument, "lucky gift config scope is invalid") + if normalizePoolID(config.PoolID) == "" { + return xerr.New(xerr.InvalidArgument, "lucky gift pool id is required") } + config.GiftID = normalizePoolID(config.PoolID) if config.GiftPrice <= 0 || config.TargetRTPPPM <= 0 || config.TargetRTPPPM > ppmScale { return xerr.New(xerr.InvalidArgument, "gift price or target RTP is invalid") } @@ -81,54 +86,110 @@ func validateConfig(config domain.Config) error { if len(config.Tiers) == 0 { return xerr.New(xerr.InvalidArgument, "reward tiers are required") } + maxMultiplierPPM := int64(0) for _, tier := range config.Tiers { - if tier.Pool == "" || tier.TierID == "" || tier.Weight < 0 || tier.RewardCoins < 0 { + if tier.Pool == "" || tier.TierID == "" || tier.Weight < 0 || tier.RewardCoins < 0 || tier.MultiplierPPM < 0 { return xerr.New(xerr.InvalidArgument, fmt.Sprintf("invalid reward tier: %s", tier.TierID)) } + if tier.Enabled && tier.MultiplierPPM > maxMultiplierPPM { + maxMultiplierPPM = tier.MultiplierPPM + } + } + if maxMultiplierPPM < config.TargetRTPPPM { + return xerr.New(xerr.InvalidArgument, "max lucky gift multiplier must not be lower than target RTP") } return nil } -func normalizeTiers(tiers []domain.Tier, giftPrice int64) []domain.Tier { - if len(tiers) == 0 { - return defaultTiers(giftPrice) +func normalizeTiers(tiers []domain.Tier, multipliers []int64, giftPrice, highMultiplier int64) ([]domain.Tier, []int64) { + multiplierPPMs := normalizeMultiplierPPMs(multipliers, tiers, giftPrice) + return buildMultiplierTiers(giftPrice, highMultiplier, multiplierPPMs), multiplierPPMs +} + +func normalizeMultiplierPPMs(input []int64, tiers []domain.Tier, giftPrice int64) []int64 { + values := append([]int64(nil), input...) + if len(values) == 0 { + for _, tier := range tiers { + if tier.MultiplierPPM > 0 || tier.RewardCoins == 0 { + values = append(values, tier.MultiplierPPM) + continue + } + if giftPrice > 0 && tier.RewardCoins > 0 { + values = append(values, tier.RewardCoins*ppmScale/giftPrice) + } + } } - out := make([]domain.Tier, 0, len(tiers)) - for _, tier := range tiers { - tier.Pool = strings.TrimSpace(tier.Pool) - tier.TierID = strings.TrimSpace(tier.TierID) - if tier.Pool == "" || tier.TierID == "" { + if len(values) == 0 { + values = defaultMultiplierPPMs() + } + seen := map[int64]bool{0: true} + out := []int64{0} + for _, value := range values { + if value < 0 || seen[value] { continue } - out = append(out, tier) + seen[value] = true + out = append(out, value) + } + sort.Slice(out, func(i, j int) bool { return out[i] < out[j] }) + return out +} + +func buildMultiplierTiers(cost, highMultiplier int64, multipliers []int64) []domain.Tier { + out := make([]domain.Tier, 0, len(multipliers)*3) + for _, pool := range []string{domain.PoolNovice, domain.PoolIntermediate, domain.PoolAdvanced} { + for _, multiplierPPM := range multipliers { + out = append(out, domain.Tier{ + Pool: pool, + TierID: pool + "_" + multiplierTierID(multiplierPPM), + RewardCoins: cost * multiplierPPM / ppmScale, + MultiplierPPM: multiplierPPM, + Weight: 0, + HighWaterOnly: highMultiplier > 0 && multiplierPPM >= highMultiplier*ppmScale, + Enabled: true, + }) + } } return out } -func defaultTiers(cost int64) []domain.Tier { - return []domain.Tier{ - {Pool: domain.PoolNovice, TierID: "none", RewardCoins: 0, Weight: 720_000, Enabled: true}, - {Pool: domain.PoolNovice, TierID: "novice_feedback_0_2x", RewardCoins: cost / 5, Weight: 90_000, Enabled: true}, - {Pool: domain.PoolNovice, TierID: "novice_rebate_0_5x", RewardCoins: cost / 2, Weight: 70_000, Enabled: true}, - {Pool: domain.PoolNovice, TierID: "novice_rebate_1x", RewardCoins: cost, Weight: 70_000, Enabled: true}, - {Pool: domain.PoolNovice, TierID: "novice_small_2x", RewardCoins: cost * 2, Weight: 35_000, Enabled: true}, - {Pool: domain.PoolNovice, TierID: "novice_small_5x", RewardCoins: cost * 5, Weight: 12_000, Enabled: true}, - {Pool: domain.PoolNovice, TierID: "novice_medium_10x", RewardCoins: cost * 10, Weight: 2_500, Enabled: true}, - {Pool: domain.PoolNovice, TierID: "novice_cap_20x", RewardCoins: cost * 20, Weight: 500, Enabled: true}, - - {Pool: domain.PoolIntermediate, TierID: "none", RewardCoins: 0, Weight: 830_000, Enabled: true}, - {Pool: domain.PoolIntermediate, TierID: "inter_rebate_0_5x", RewardCoins: cost / 2, Weight: 25_000, Enabled: true}, - {Pool: domain.PoolIntermediate, TierID: "inter_rebate_1x", RewardCoins: cost, Weight: 40_000, Enabled: true}, - {Pool: domain.PoolIntermediate, TierID: "inter_small_2x", RewardCoins: cost * 2, Weight: 45_000, Enabled: true}, - {Pool: domain.PoolIntermediate, TierID: "inter_medium_5x", RewardCoins: cost * 5, Weight: 35_000, Enabled: true}, - {Pool: domain.PoolIntermediate, TierID: "inter_large_20x", RewardCoins: cost * 20, Weight: 20_000, HighWaterOnly: true, Enabled: true}, - {Pool: domain.PoolIntermediate, TierID: "inter_large_50x", RewardCoins: cost * 50, Weight: 5_000, HighWaterOnly: true, Enabled: true}, - - {Pool: domain.PoolAdvanced, TierID: "none", RewardCoins: 0, Weight: 900_000, Enabled: true}, - {Pool: domain.PoolAdvanced, TierID: "adv_small_2x", RewardCoins: cost * 2, Weight: 30_000, Enabled: true}, - {Pool: domain.PoolAdvanced, TierID: "adv_medium_5x", RewardCoins: cost * 5, Weight: 30_000, Enabled: true}, - {Pool: domain.PoolAdvanced, TierID: "adv_large_20x", RewardCoins: cost * 20, Weight: 30_000, HighWaterOnly: true, Enabled: true}, - {Pool: domain.PoolAdvanced, TierID: "adv_large_100x", RewardCoins: cost * 100, Weight: 9_000, HighWaterOnly: true, Enabled: true}, - {Pool: domain.PoolAdvanced, TierID: "adv_jackpot_500x", RewardCoins: cost * 500, Weight: 1_000, HighWaterOnly: true, Enabled: true}, +func defaultMultiplierPPMs() []int64 { + return []int64{ + 0, + 200_000, + 500_000, + 1_000_000, + 2_000_000, + 5_000_000, + 10_000_000, + 20_000_000, + 50_000_000, + 100_000_000, + 500_000_000, } } + +func defaultTiers(cost int64) []domain.Tier { + return buildMultiplierTiers(cost, 100, defaultMultiplierPPMs()) +} + +func multiplierTierID(multiplierPPM int64) string { + if multiplierPPM == 0 { + return "none" + } + whole := multiplierPPM / ppmScale + fraction := multiplierPPM % ppmScale + if fraction == 0 { + return fmt.Sprintf("%dx", whole) + } + text := strings.TrimRight(strings.TrimRight(fmt.Sprintf("%d_%06dx", whole, fraction), "0"), "_") + return text +} + +func normalizePoolID(poolID string) string { + poolID = strings.TrimSpace(poolID) + if poolID == "" { + return domain.DefaultPoolID + } + return poolID +} diff --git a/services/activity-service/internal/service/luckygift/config_test.go b/services/activity-service/internal/service/luckygift/config_test.go index 99db3808..6e37ccd2 100644 --- a/services/activity-service/internal/service/luckygift/config_test.go +++ b/services/activity-service/internal/service/luckygift/config_test.go @@ -2,12 +2,10 @@ package luckygift import ( "testing" - - domain "hyapp/services/activity-service/internal/domain/luckygift" ) func TestDefaultConfigPassesValidation(t *testing.T) { - config := DefaultConfig("hyapp") + config := DefaultConfig("hyapp", "pool_95") config.Enabled = true if err := validateConfig(config); err != nil { @@ -15,30 +13,40 @@ func TestDefaultConfigPassesValidation(t *testing.T) { } } -func TestValidateConfigRejectsGiftScopedRules(t *testing.T) { - config := DefaultConfig("hyapp") - config.GiftID = "gift_500" +func TestValidateConfigAcceptsIndependentPoolScope(t *testing.T) { + config := DefaultConfig("hyapp", "pool_98") + config.Enabled = true + + if err := validateConfig(config); err != nil { + t.Fatalf("expected pool scoped lucky gift config to pass: %v", err) + } +} + +func TestNormalizeTiersBuildsMultiplierDrivenTiers(t *testing.T) { + tiers, multipliers := normalizeTiers(nil, []int64{0, 500_000, 2_000_000}, 500, 100) + + if len(multipliers) != 3 || multipliers[1] != 500_000 || multipliers[2] != 2_000_000 { + t.Fatalf("expected normalized multiplier list, got %#v", multipliers) + } + if len(tiers) != 9 { + t.Fatalf("expected three pools times three multipliers, got %d", len(tiers)) + } + for _, tier := range tiers { + if !tier.Enabled || tier.Weight != 0 { + t.Fatalf("tier should be enabled with runtime-generated weight: %#v", tier) + } + if tier.MultiplierPPM == 500_000 && tier.RewardCoins != 250 { + t.Fatalf("expected 0.5x reward to follow reference cost, got %#v", tier) + } + } +} + +func TestValidateConfigRejectsMultiplierListBelowTargetRTP(t *testing.T) { + config := DefaultConfig("hyapp", "pool_low") + config.MultiplierPPMs = []int64{0, 500_000} + config.Tiers, config.MultiplierPPMs = normalizeTiers(nil, config.MultiplierPPMs, config.GiftPrice, config.HighMultiplier) if err := validateConfig(config); err == nil { - t.Fatalf("expected gift scoped lucky gift config to be rejected") - } -} - -func TestNormalizeTiersPreservesDisabledOperationalTiers(t *testing.T) { - tiers := normalizeTiers([]domain.Tier{ - { - Pool: domain.PoolAdvanced, - TierID: "adv_jackpot_500x", - RewardCoins: 250_000, - Weight: 1_000, - Enabled: false, - }, - }, 500) - - if len(tiers) != 1 { - t.Fatalf("expected one normalized tier, got %d", len(tiers)) - } - if tiers[0].Enabled { - t.Fatalf("disabled tier was re-enabled during normalization") + t.Fatalf("expected max multiplier below RTP to be rejected") } } diff --git a/services/activity-service/internal/service/luckygift/service.go b/services/activity-service/internal/service/luckygift/service.go index 973c2cbf..beeb82f9 100644 --- a/services/activity-service/internal/service/luckygift/service.go +++ b/services/activity-service/internal/service/luckygift/service.go @@ -12,7 +12,7 @@ import ( // Repository 是幸运礼物唯一持久化边界;RTP、奖池、风控计数必须在同一个 MySQL 事务内修改。 type Repository interface { - GetLuckyGiftConfig(ctx context.Context) (domain.Config, bool, error) + GetLuckyGiftConfig(ctx context.Context, poolID string) (domain.Config, bool, error) UpsertLuckyGiftConfig(ctx context.Context, config domain.Config, nowMS int64) (domain.Config, error) CheckLuckyGift(ctx context.Context, cmd domain.CheckCommand) (domain.CheckResult, error) ExecuteLuckyGiftDraw(ctx context.Context, cmd domain.DrawCommand, nowMS int64) (domain.DrawResult, error) @@ -39,6 +39,7 @@ func (s *Service) Check(ctx context.Context, cmd domain.CheckCommand) (domain.Ch if err := s.requireRepository(); err != nil { return domain.CheckResult{}, err } + cmd.PoolID = normalizePoolID(cmd.PoolID) cmd.GiftID = strings.TrimSpace(cmd.GiftID) cmd.RoomID = strings.TrimSpace(cmd.RoomID) if cmd.GiftID == "" || cmd.UserID <= 0 || cmd.RoomID == "" { @@ -52,6 +53,7 @@ func (s *Service) Draw(ctx context.Context, cmd domain.DrawCommand) (domain.Draw return domain.DrawResult{}, err } cmd.CommandID = strings.TrimSpace(cmd.CommandID) + cmd.PoolID = normalizePoolID(cmd.PoolID) cmd.GiftID = strings.TrimSpace(cmd.GiftID) cmd.DeviceID = strings.TrimSpace(cmd.DeviceID) cmd.RoomID = strings.TrimSpace(cmd.RoomID) @@ -65,16 +67,17 @@ func (s *Service) Draw(ctx context.Context, cmd domain.DrawCommand) (domain.Draw return s.repository.ExecuteLuckyGiftDraw(ctx, cmd, s.now().UTC().UnixMilli()) } -func (s *Service) GetConfig(ctx context.Context) (domain.Config, error) { +func (s *Service) GetConfig(ctx context.Context, poolID string) (domain.Config, error) { if err := s.requireRepository(); err != nil { return domain.Config{}, err } - config, exists, err := s.repository.GetLuckyGiftConfig(ctx) + poolID = normalizePoolID(poolID) + config, exists, err := s.repository.GetLuckyGiftConfig(ctx, poolID) if err != nil { return domain.Config{}, err } if !exists { - return DefaultConfig(appcode.FromContext(ctx)), nil + return DefaultConfig(appcode.FromContext(ctx), poolID), nil } return config, nil } @@ -84,8 +87,9 @@ func (s *Service) UpsertConfig(ctx context.Context, config domain.Config) (domai return domain.Config{}, err } config.AppCode = appcode.FromContext(ctx) - config.GiftID = domain.GlobalConfigGiftID - config.Tiers = normalizeTiers(config.Tiers, config.GiftPrice) + config.PoolID = normalizePoolID(config.PoolID) + config.GiftID = config.PoolID + config.Tiers, config.MultiplierPPMs = normalizeTiers(config.Tiers, config.MultiplierPPMs, config.GiftPrice, config.HighMultiplier) if err := validateConfig(config); err != nil { return domain.Config{}, err } @@ -96,6 +100,7 @@ func (s *Service) ListDraws(ctx context.Context, query domain.DrawQuery) ([]doma if err := s.requireRepository(); err != nil { return nil, 0, err } + query.PoolID = normalizePoolID(query.PoolID) query.GiftID = strings.TrimSpace(query.GiftID) query.RoomID = strings.TrimSpace(query.RoomID) query.Status = strings.TrimSpace(query.Status) diff --git a/services/activity-service/internal/service/luckygift/service_test.go b/services/activity-service/internal/service/luckygift/service_test.go index ac6131dd..ea819df0 100644 --- a/services/activity-service/internal/service/luckygift/service_test.go +++ b/services/activity-service/internal/service/luckygift/service_test.go @@ -8,34 +8,33 @@ import ( domain "hyapp/services/activity-service/internal/domain/luckygift" ) -func TestGetConfigReturnsGlobalDefault(t *testing.T) { +func TestGetConfigReturnsPoolDefault(t *testing.T) { repository := &fakeLuckyGiftRepository{} service := New(repository) - config, err := service.GetConfig(appcode.WithContext(context.Background(), "hyapp")) + config, err := service.GetConfig(appcode.WithContext(context.Background(), "hyapp"), "pool_95") if err != nil { t.Fatalf("GetConfig returned error: %v", err) } if repository.getConfigCalls != 1 { t.Fatalf("expected repository to be called once, got %d", repository.getConfigCalls) } - if config.GiftID != domain.GlobalConfigGiftID { - t.Fatalf("expected global config scope, got %q", config.GiftID) + if config.PoolID != "pool_95" || config.GiftID != "pool_95" { + t.Fatalf("expected pool config scope, got pool=%q gift=%q", config.PoolID, config.GiftID) } } -func TestUpsertConfigForcesGlobalScope(t *testing.T) { +func TestUpsertConfigPreservesPoolScope(t *testing.T) { repository := &fakeLuckyGiftRepository{} service := New(repository) - config := DefaultConfig("hyapp") - config.GiftID = "gift_500" + config := DefaultConfig("hyapp", "pool_98") config.Enabled = true if _, err := service.UpsertConfig(appcode.WithContext(context.Background(), "hyapp"), config); err != nil { t.Fatalf("UpsertConfig returned error: %v", err) } - if repository.upserted.GiftID != domain.GlobalConfigGiftID { - t.Fatalf("expected global config scope, got %q", repository.upserted.GiftID) + if repository.upserted.PoolID != "pool_98" || repository.upserted.GiftID != "pool_98" { + t.Fatalf("expected pool scope, got pool=%q gift=%q", repository.upserted.PoolID, repository.upserted.GiftID) } } @@ -44,7 +43,7 @@ type fakeLuckyGiftRepository struct { upserted domain.Config } -func (r *fakeLuckyGiftRepository) GetLuckyGiftConfig(context.Context) (domain.Config, bool, error) { +func (r *fakeLuckyGiftRepository) GetLuckyGiftConfig(context.Context, string) (domain.Config, bool, error) { r.getConfigCalls++ return domain.Config{}, false, nil } diff --git a/services/activity-service/internal/storage/mysql/lucky_gift_repository.go b/services/activity-service/internal/storage/mysql/lucky_gift_repository.go index ce784bb0..7edaa06f 100644 --- a/services/activity-service/internal/storage/mysql/lucky_gift_repository.go +++ b/services/activity-service/internal/storage/mysql/lucky_gift_repository.go @@ -8,6 +8,7 @@ import ( "errors" "fmt" "math/big" + "sort" "strings" "time" @@ -109,6 +110,7 @@ type luckyCandidate struct { Pool string Weight int64 BaseReward int64 + MultiplierPPM int64 RoomReward int64 ActivityReward int64 Presentation bool @@ -120,12 +122,13 @@ func (c luckyCandidate) effectiveReward() int64 { return c.BaseReward + c.RoomReward + c.ActivityReward } -// GetLuckyGiftConfig 返回幸运礼物整体规则;未配置由用例层转为默认草稿。 -func (r *Repository) GetLuckyGiftConfig(ctx context.Context) (domain.Config, bool, error) { +// GetLuckyGiftConfig 返回指定幸运礼物奖池规则;未配置由用例层转为默认草稿。 +func (r *Repository) GetLuckyGiftConfig(ctx context.Context, poolID string) (domain.Config, bool, error) { if r == nil || r.db == nil { return domain.Config{}, false, xerr.New(xerr.Unavailable, "mysql repository is not configured") } - return r.getLuckyGiftConfig(ctx, r.db, appcode.FromContext(ctx), domain.GlobalConfigGiftID, false) + poolID = luckyPoolID(poolID) + return r.getLuckyGiftConfig(ctx, r.db, appcode.FromContext(ctx), poolID, false) } // UpsertLuckyGiftConfig 发布新的不可变规则版本。运行中窗口不回退,新版本只影响后续抽奖。 @@ -140,7 +143,8 @@ func (r *Repository) UpsertLuckyGiftConfig(ctx context.Context, config domain.Co defer func() { _ = tx.Rollback() }() appCode := appcode.FromContext(ctx) - config.GiftID = domain.GlobalConfigGiftID + config.PoolID = luckyPoolID(config.PoolID) + config.GiftID = config.PoolID current, exists, err := r.getLuckyGiftConfig(ctx, tx, appCode, config.GiftID, true) if err != nil { return domain.Config{}, err @@ -229,12 +233,13 @@ func (r *Repository) UpsertLuckyGiftConfig(ctx context.Context, config domain.Co } func (r *Repository) CheckLuckyGift(ctx context.Context, cmd domain.CheckCommand) (domain.CheckResult, error) { - config, exists, err := r.GetLuckyGiftConfig(ctx) + poolID := luckyPoolID(cmd.PoolID) + config, exists, err := r.GetLuckyGiftConfig(ctx, poolID) if err != nil { return domain.CheckResult{}, err } if !exists { - return domain.CheckResult{Enabled: false, Reason: "not_configured", GiftID: cmd.GiftID}, nil + return domain.CheckResult{Enabled: false, Reason: "not_configured", PoolID: poolID, GiftID: cmd.GiftID}, nil } paidDraws, err := r.getLuckyUserPaidDraws(ctx, appcode.FromContext(ctx), cmd.UserID, config.GiftID) if err != nil { @@ -243,6 +248,7 @@ func (r *Repository) CheckLuckyGift(ctx context.Context, cmd domain.CheckCommand return domain.CheckResult{ Enabled: config.Enabled, Reason: luckyEnabledReason(config.Enabled), + PoolID: config.GiftID, GiftID: cmd.GiftID, GiftPrice: config.GiftPrice, RuleVersion: config.RuleVersion, @@ -263,6 +269,7 @@ func (r *Repository) ExecuteLuckyGiftDraw(ctx context.Context, cmd domain.DrawCo defer func() { _ = tx.Rollback() }() appCode := appcode.FromContext(ctx) + poolID := luckyPoolID(cmd.PoolID) if existing, exists, err := r.getLuckyDrawByCommand(ctx, tx, appCode, cmd.CommandID, true); err != nil || exists { if err != nil { return domain.DrawResult{}, err @@ -273,7 +280,7 @@ func (r *Repository) ExecuteLuckyGiftDraw(ctx context.Context, cmd domain.DrawCo return existing, nil } - config, exists, err := r.getLuckyGiftConfig(ctx, tx, appCode, domain.GlobalConfigGiftID, true) + config, exists, err := r.getLuckyGiftConfig(ctx, tx, appCode, poolID, true) if err != nil { return domain.DrawResult{}, err } @@ -291,22 +298,22 @@ func (r *Repository) ExecuteLuckyGiftDraw(ctx context.Context, cmd domain.DrawCo stageFeedback := luckyStageFeedback(nextPaidDraw, config) minFutureMax := luckyMinFutureMax(config) - globalWindow, err := r.getOpenLuckyRTPWindow(ctx, tx, appCode, "global", "global", config.GlobalWindowDraws, config.TargetRTPPPM, nowMS) + globalWindow, err := r.getOpenLuckyRTPWindow(ctx, tx, appCode, "pool", config.GiftID, config.GlobalWindowDraws, config.TargetRTPPPM, nowMS) if err != nil { return domain.DrawResult{}, err } globalWindow = globalWindow.withCurrentWager(cmd.CoinSpent) - giftWindow, err := r.getOpenLuckyRTPWindow(ctx, tx, appCode, "gift", config.GiftID, config.GiftWindowDraws, config.TargetRTPPPM, nowMS) + giftWindow, err := r.getOpenLuckyRTPWindow(ctx, tx, appCode, "pool_gift", config.GiftID, config.GiftWindowDraws, config.TargetRTPPPM, nowMS) if err != nil { return domain.DrawResult{}, err } giftWindow = giftWindow.withCurrentWager(cmd.CoinSpent) - platformPool, err := r.getOrCreateLuckyPool(ctx, tx, appCode, "platform", "global", config.InitialPlatformPool, config.PlatformReserve, nowMS) + platformPool, err := r.getOrCreateLuckyPool(ctx, tx, appCode, "platform", config.GiftID, config.InitialPlatformPool, config.PlatformReserve, nowMS) if err != nil { return domain.DrawResult{}, err } - roomPool, err := r.getOrCreateLuckyPool(ctx, tx, appCode, "room", cmd.RoomID, config.InitialRoomPool, config.RoomReserve, nowMS) + roomPool, err := r.getOrCreateLuckyPool(ctx, tx, appCode, "room", luckyPoolScopedID(config.GiftID, cmd.RoomID), config.InitialRoomPool, config.RoomReserve, nowMS) if err != nil { return domain.DrawResult{}, err } @@ -314,21 +321,21 @@ func (r *Repository) ExecuteLuckyGiftDraw(ctx context.Context, cmd domain.DrawCo if err != nil { return domain.DrawResult{}, err } - atmosphere, err := r.getOrCreateLuckyAtmosphere(ctx, tx, appCode, cmd.RoomID, config.RoomAtmosphereInitial, config.RoomAtmosphereReserve, nowMS) + atmosphere, err := r.getOrCreateLuckyAtmosphere(ctx, tx, appCode, luckyPoolScopedID(config.GiftID, cmd.RoomID), config.RoomAtmosphereInitial, config.RoomAtmosphereReserve, nowMS) if err != nil { return domain.DrawResult{}, err } if err := r.creditLuckyBasePools(ctx, tx, appCode, config, cmd.CoinSpent, &platformPool, &roomPool, &giftPool, nowMS); err != nil { return domain.DrawResult{}, err } - if err := r.creditLuckyAtmosphere(ctx, tx, appCode, cmd.RoomID, cmd.CoinSpent*config.RoomAtmosphereRatePPM/luckyPPMScale, &atmosphere, nowMS); err != nil { + if err := r.creditLuckyAtmosphere(ctx, tx, appCode, atmosphere.ScopeID, cmd.CoinSpent*config.RoomAtmosphereRatePPM/luckyPPMScale, &atmosphere, nowMS); err != nil { return domain.DrawResult{}, err } activityRemaining, activityDay, err := r.getLuckyActivityRemaining(ctx, tx, appCode, config, cmd.PaidAtMS, nowMS) if err != nil { return domain.DrawResult{}, err } - counters, err := r.getLuckyRiskCounters(ctx, tx, appCode, cmd, nowMS) + counters, err := r.getLuckyRiskCounters(ctx, tx, appCode, config.GiftID, cmd, nowMS) if err != nil { return domain.DrawResult{}, err } @@ -368,6 +375,7 @@ func (r *Repository) ExecuteLuckyGiftDraw(ctx context.Context, cmd domain.DrawCo return domain.DrawResult{ DrawID: drawID, CommandID: cmd.CommandID, + PoolID: config.GiftID, GiftID: cmd.GiftID, RuleVersion: config.RuleVersion, ExperiencePool: experiencePool, @@ -438,10 +446,10 @@ func (r *Repository) applyLuckyDraw(ctx context.Context, tx *sql.Tx, input lucky ); err != nil { return err } - if err := r.debitLuckyPool(ctx, tx, input.AppCode, "platform", "global", platformOut, input.NowMS); err != nil { + if err := r.debitLuckyPool(ctx, tx, input.AppCode, "platform", input.Config.GiftID, platformOut, input.NowMS); err != nil { return err } - if err := r.debitLuckyPool(ctx, tx, input.AppCode, "room", input.Command.RoomID, roomOut, input.NowMS); err != nil { + if err := r.debitLuckyPool(ctx, tx, input.AppCode, "room", input.RoomPool.ScopeID, roomOut, input.NowMS); err != nil { return err } if err := r.debitLuckyPool(ctx, tx, input.AppCode, "gift", input.GiftPool.ScopeID, giftOut, input.NowMS); err != nil { @@ -452,7 +460,7 @@ func (r *Repository) applyLuckyDraw(ctx context.Context, tx *sql.Tx, input lucky UPDATE lucky_room_atmosphere_pools SET balance = balance - ?, total_out = total_out + ?, updated_at_ms = ? WHERE app_code = ? AND room_id = ?`, - input.Candidate.RoomReward, input.Candidate.RoomReward, input.NowMS, input.AppCode, input.Command.RoomID, + input.Candidate.RoomReward, input.Candidate.RoomReward, input.NowMS, input.AppCode, input.Atmosphere.ScopeID, ); err != nil { return err } @@ -478,7 +486,7 @@ func (r *Repository) applyLuckyDraw(ctx context.Context, tx *sql.Tx, input lucky return err } } - if err := r.updateLuckyRiskCounters(ctx, tx, input.AppCode, input.Command, input.Candidate.effectiveReward(), input.NowMS); err != nil { + if err := r.updateLuckyRiskCounters(ctx, tx, input.AppCode, input.Config.GiftID, input.Command, input.Candidate.effectiveReward(), input.NowMS); err != nil { return err } if _, err := tx.ExecContext(ctx, ` @@ -490,8 +498,11 @@ func (r *Repository) applyLuckyDraw(ctx context.Context, tx *sql.Tx, input lucky return err } candidateSnapshot, _ := json.Marshal(map[string]any{ - "limited": input.Limited, - "source": input.Candidate.Source, + "base_reward": input.Candidate.BaseReward, + "limited": input.Limited, + "multiplier_ppm": input.Candidate.MultiplierPPM, + "source": input.Candidate.Source, + "weight": input.Candidate.Weight, }) poolSnapshot, _ := json.Marshal(map[string]any{ "platform_balance": input.PlatformPool.Balance, @@ -505,15 +516,15 @@ func (r *Repository) applyLuckyDraw(ctx context.Context, tx *sql.Tx, input lucky }) if _, err := tx.ExecContext(ctx, ` INSERT INTO lucky_draw_records ( - app_code, draw_id, command_id, user_id, device_id, room_id, anchor_id, gift_id, + app_code, draw_id, command_id, user_id, device_id, room_id, anchor_id, pool_id, gift_id, coin_spent, rule_version, experience_pool, rtp_window_index, gift_rtp_window_index, selected_tier_id, base_reward_coins, room_atmosphere_reward_coins, activity_subsidy_coins, effective_reward_coins, budget_sources_json, stage_feedback, high_multiplier, candidate_tiers_json, pool_snapshot_json, rtp_snapshot_json, reward_status, paid_at_ms, created_at_ms, updated_at_ms - ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`, + ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`, input.AppCode, input.DrawID, input.Command.CommandID, input.Command.UserID, input.Command.DeviceID, - input.Command.RoomID, input.Command.AnchorID, input.Command.GiftID, input.Command.CoinSpent, + input.Command.RoomID, input.Command.AnchorID, input.Config.GiftID, input.Command.GiftID, input.Command.CoinSpent, input.Config.RuleVersion, input.ExperiencePool, input.GlobalWindow.WindowIndex, input.GiftWindow.WindowIndex, input.Candidate.TierID, input.Candidate.BaseReward, input.Candidate.RoomReward, input.Candidate.ActivityReward, input.Candidate.effectiveReward(), input.BudgetSourcesJSON, input.StageFeedback, input.Candidate.HighMultiplier, @@ -525,6 +536,7 @@ func (r *Repository) applyLuckyDraw(ctx context.Context, tx *sql.Tx, input lucky if input.Candidate.effectiveReward() > 0 || input.StageFeedback || input.Candidate.Presentation { payload, _ := json.Marshal(map[string]any{ "draw_id": input.DrawID, + "pool_id": input.Config.GiftID, "user_id": input.Command.UserID, "room_id": input.Command.RoomID, "gift_id": input.Command.GiftID, @@ -550,6 +562,10 @@ func (r *Repository) ListLuckyGiftDraws(ctx context.Context, query domain.DrawQu appCode := appcode.FromContext(ctx) where := []string{"app_code = ?"} args := []any{appCode} + if query.PoolID != "" { + where = append(where, "pool_id = ?") + args = append(args, query.PoolID) + } if query.GiftID != "" { where = append(where, "gift_id = ?") args = append(args, query.GiftID) @@ -575,7 +591,7 @@ func (r *Repository) ListLuckyGiftDraws(ctx context.Context, query domain.DrawQu offset := int((query.Page - 1) * query.PageSize) args = append(args, limit, offset) rows, err := r.db.QueryContext(ctx, ` - SELECT draw_id, command_id, gift_id, rule_version, experience_pool, selected_tier_id, + SELECT draw_id, command_id, pool_id, gift_id, rule_version, experience_pool, selected_tier_id, base_reward_coins, room_atmosphere_reward_coins, activity_subsidy_coins, effective_reward_coins, COALESCE(CAST(budget_sources_json AS CHAR), ''), reward_status, rtp_window_index, gift_rtp_window_index, stage_feedback, high_multiplier, created_at_ms @@ -590,7 +606,7 @@ func (r *Repository) ListLuckyGiftDraws(ctx context.Context, query domain.DrawQu items := make([]domain.DrawResult, 0, limit) for rows.Next() { var item domain.DrawResult - if err := rows.Scan(&item.DrawID, &item.CommandID, &item.GiftID, &item.RuleVersion, &item.ExperiencePool, &item.SelectedTierID, + if err := rows.Scan(&item.DrawID, &item.CommandID, &item.PoolID, &item.GiftID, &item.RuleVersion, &item.ExperiencePool, &item.SelectedTierID, &item.BaseRewardCoins, &item.RoomAtmosphereRewardCoins, &item.ActivitySubsidyCoins, &item.EffectiveRewardCoins, &item.BudgetSourcesJSON, &item.RewardStatus, &item.RTPWindowIndex, &item.GiftRTPWindowIndex, &item.StageFeedback, &item.HighMultiplier, &item.CreatedAtMS); err != nil { @@ -646,6 +662,8 @@ func (r *Repository) getLuckyGiftConfig(ctx context.Context, q luckyQueryer, app if err := json.Unmarshal([]byte(tiersJSON), &config.Tiers); err != nil { return domain.Config{}, false, err } + config.PoolID = config.GiftID + config.MultiplierPPMs = luckyMultiplierPPMsFromTiers(config.Tiers, config.GiftPrice) return config, true, nil } @@ -817,7 +835,7 @@ func (r *Repository) getOrCreateLuckyAtmosphere(ctx context.Context, tx *sql.Tx, func (r *Repository) creditLuckyBasePools(ctx context.Context, tx *sql.Tx, appCode string, config domain.Config, spent int64, platform, room, gift *luckyPool, nowMS int64) error { poolIn := spent * config.PoolRatePPM / luckyPPMScale platformIn, roomIn, giftIn := luckySplitWeighted(poolIn, config) - if err := r.creditLuckyPool(ctx, tx, appCode, "platform", "global", platformIn, platform, nowMS); err != nil { + if err := r.creditLuckyPool(ctx, tx, appCode, "platform", config.GiftID, platformIn, platform, nowMS); err != nil { return err } if err := r.creditLuckyPool(ctx, tx, appCode, "room", room.ScopeID, roomIn, room, nowMS); err != nil { @@ -969,15 +987,15 @@ func (r *Repository) getOrCreateLuckyActivityTotalRemaining(ctx context.Context, return maxInt64(0, budget-spent), nil } -func (r *Repository) getLuckyRiskCounters(ctx context.Context, tx *sql.Tx, appCode string, cmd domain.DrawCommand, nowMS int64) (map[string]luckyCounter, error) { +func (r *Repository) getLuckyRiskCounters(ctx context.Context, tx *sql.Tx, appCode, poolID string, cmd domain.DrawCommand, nowMS int64) (map[string]luckyCounter, error) { hour := time.UnixMilli(cmd.PaidAtMS).UTC().Format("2006-01-02T15") day := time.UnixMilli(cmd.PaidAtMS).UTC().Format("2006-01-02") specs := []luckyCounter{ - {ScopeType: "user", ScopeID: fmt.Sprintf("%d", cmd.UserID), WindowType: "hour", BucketKey: hour}, - {ScopeType: "user", ScopeID: fmt.Sprintf("%d", cmd.UserID), WindowType: "day", BucketKey: day}, - {ScopeType: "device", ScopeID: cmd.DeviceID, WindowType: "day", BucketKey: day}, - {ScopeType: "room", ScopeID: cmd.RoomID, WindowType: "hour", BucketKey: hour}, - {ScopeType: "anchor", ScopeID: cmd.AnchorID, WindowType: "day", BucketKey: day}, + {ScopeType: "user", ScopeID: luckyPoolScopedID(poolID, fmt.Sprintf("%d", cmd.UserID)), WindowType: "hour", BucketKey: hour}, + {ScopeType: "user", ScopeID: luckyPoolScopedID(poolID, fmt.Sprintf("%d", cmd.UserID)), WindowType: "day", BucketKey: day}, + {ScopeType: "device", ScopeID: luckyPoolScopedID(poolID, cmd.DeviceID), WindowType: "day", BucketKey: day}, + {ScopeType: "room", ScopeID: luckyPoolScopedID(poolID, cmd.RoomID), WindowType: "hour", BucketKey: hour}, + {ScopeType: "anchor", ScopeID: luckyPoolScopedID(poolID, cmd.AnchorID), WindowType: "day", BucketKey: day}, } out := make(map[string]luckyCounter, len(specs)) for _, spec := range specs { @@ -1014,18 +1032,18 @@ func (r *Repository) getOrCreateLuckyRiskCounter(ctx context.Context, tx *sql.Tx return spec, nil } -func (r *Repository) updateLuckyRiskCounters(ctx context.Context, tx *sql.Tx, appCode string, cmd domain.DrawCommand, payout int64, nowMS int64) error { +func (r *Repository) updateLuckyRiskCounters(ctx context.Context, tx *sql.Tx, appCode, poolID string, cmd domain.DrawCommand, payout int64, nowMS int64) error { if payout <= 0 { return nil } hour := time.UnixMilli(cmd.PaidAtMS).UTC().Format("2006-01-02T15") day := time.UnixMilli(cmd.PaidAtMS).UTC().Format("2006-01-02") specs := []luckyCounter{ - {ScopeType: "user", ScopeID: fmt.Sprintf("%d", cmd.UserID), WindowType: "hour", BucketKey: hour}, - {ScopeType: "user", ScopeID: fmt.Sprintf("%d", cmd.UserID), WindowType: "day", BucketKey: day}, - {ScopeType: "device", ScopeID: cmd.DeviceID, WindowType: "day", BucketKey: day}, - {ScopeType: "room", ScopeID: cmd.RoomID, WindowType: "hour", BucketKey: hour}, - {ScopeType: "anchor", ScopeID: cmd.AnchorID, WindowType: "day", BucketKey: day}, + {ScopeType: "user", ScopeID: luckyPoolScopedID(poolID, fmt.Sprintf("%d", cmd.UserID)), WindowType: "hour", BucketKey: hour}, + {ScopeType: "user", ScopeID: luckyPoolScopedID(poolID, fmt.Sprintf("%d", cmd.UserID)), WindowType: "day", BucketKey: day}, + {ScopeType: "device", ScopeID: luckyPoolScopedID(poolID, cmd.DeviceID), WindowType: "day", BucketKey: day}, + {ScopeType: "room", ScopeID: luckyPoolScopedID(poolID, cmd.RoomID), WindowType: "hour", BucketKey: hour}, + {ScopeType: "anchor", ScopeID: luckyPoolScopedID(poolID, cmd.AnchorID), WindowType: "day", BucketKey: day}, } for _, spec := range specs { if _, err := tx.ExecContext(ctx, ` @@ -1048,17 +1066,18 @@ func (r *Repository) selectLuckyCandidate(config domain.Config, pool string, sta baseMaxAllowed := minInt64(rtpMaxAllowed, minInt64(poolCapacity, riskCapacity)) limited := map[string]bool{} - candidates := make([]luckyCandidate, 0, len(config.Tiers)+4) + baseCandidates := make([]luckyCandidate, 0, len(config.Tiers)) + hasZeroCandidate := false var totalWeight int64 - var disabledWeight int64 for _, tier := range config.Tiers { - if tier.Pool != pool || !tier.Enabled || tier.Weight <= 0 { + if tier.Pool != pool || !tier.Enabled { continue } - if tier.HighWaterOnly && !config.LargeTierEnabled { - if minRequired == 0 { - disabledWeight += tier.Weight - } + if tier.RewardCoins == 0 { + hasZeroCandidate = true + } + highMultiplier := tier.HighWaterOnly || tier.RewardCoins >= config.GiftPrice*config.HighMultiplier + if highMultiplier && !config.LargeTierEnabled { limited["large_tier_disabled"] = true continue } @@ -1072,32 +1091,27 @@ func (r *Repository) selectLuckyCandidate(config domain.Config, pool string, sta if tier.RewardCoins > poolCapacity { limited["pool_cap"] = true } - if minRequired == 0 { - disabledWeight += tier.Weight - } continue } - highMultiplier := tier.HighWaterOnly || tier.RewardCoins >= config.GiftPrice*config.HighMultiplier if highMultiplier && !luckyHasHighWater(tier.RewardCoins, globalWindow, giftWindow, poolCapacity, config) { limited["high_water"] = true - if minRequired == 0 { - disabledWeight += tier.Weight - } continue } - candidates = append(candidates, luckyCandidate{ + baseCandidates = append(baseCandidates, luckyCandidate{ TierID: tier.TierID, Source: domain.SourceBaseRTP, Pool: pool, - Weight: tier.Weight, BaseReward: tier.RewardCoins, + MultiplierPPM: tier.MultiplierPPM, HighMultiplier: highMultiplier, }) - totalWeight += tier.Weight } - if disabledWeight > 0 && minRequired == 0 { - candidates = append(candidates, luckyCandidate{TierID: "disabled_to_none", Source: domain.SourceBaseRTP, Pool: pool, Weight: disabledWeight}) - totalWeight += disabledWeight + if minRequired == 0 && !hasZeroCandidate { + baseCandidates = append(baseCandidates, luckyCandidate{TierID: pool + "_none", Source: domain.SourceBaseRTP, Pool: pool}) + } + candidates := luckyAutoWeightedBaseCandidates(baseCandidates, minRequired, globalWindow, giftWindow) + for _, candidate := range candidates { + totalWeight += candidate.Weight } if minRequired == 0 { if atmosphere.capacity() >= config.GiftPrice && riskCapacity >= config.GiftPrice { @@ -1149,7 +1163,7 @@ func (r *Repository) getLuckyDrawByCommand(ctx context.Context, tx *sql.Tx, appC lockSQL = " FOR UPDATE" } row := tx.QueryRowContext(ctx, ` - SELECT draw_id, command_id, gift_id, rule_version, experience_pool, selected_tier_id, + SELECT draw_id, command_id, pool_id, gift_id, rule_version, experience_pool, selected_tier_id, base_reward_coins, room_atmosphere_reward_coins, activity_subsidy_coins, effective_reward_coins, COALESCE(CAST(budget_sources_json AS CHAR), ''), reward_status, rtp_window_index, gift_rtp_window_index, stage_feedback, high_multiplier, created_at_ms @@ -1158,7 +1172,7 @@ func (r *Repository) getLuckyDrawByCommand(ctx context.Context, tx *sql.Tx, appC appCode, commandID, ) var item domain.DrawResult - if err := row.Scan(&item.DrawID, &item.CommandID, &item.GiftID, &item.RuleVersion, &item.ExperiencePool, &item.SelectedTierID, + if err := row.Scan(&item.DrawID, &item.CommandID, &item.PoolID, &item.GiftID, &item.RuleVersion, &item.ExperiencePool, &item.SelectedTierID, &item.BaseRewardCoins, &item.RoomAtmosphereRewardCoins, &item.ActivitySubsidyCoins, &item.EffectiveRewardCoins, &item.BudgetSourcesJSON, &item.RewardStatus, &item.RTPWindowIndex, &item.GiftRTPWindowIndex, &item.StageFeedback, &item.HighMultiplier, &item.CreatedAtMS); err != nil { @@ -1245,6 +1259,90 @@ func luckyMinFutureMax(config domain.Config) int64 { return minMax } +func luckyAutoWeightedBaseCandidates(candidates []luckyCandidate, minRequired int64, globalWindow, giftWindow luckyRTPWindow) []luckyCandidate { + if len(candidates) == 0 { + return nil + } + candidates = luckyDedupeRewardCandidates(candidates) + desired := maxInt64(minRequired, luckyDesiredBaseReward(globalWindow, giftWindow)) + if desired <= 0 { + zero := candidates[0] + for _, candidate := range candidates { + if candidate.BaseReward == 0 { + zero = candidate + break + } + } + zero.Weight = luckyPPMScale + return []luckyCandidate{zero} + } + if desired <= candidates[0].BaseReward || len(candidates) == 1 { + first := candidates[0] + first.Weight = luckyPPMScale + return []luckyCandidate{first} + } + for index := 1; index < len(candidates); index++ { + lower := candidates[index-1] + upper := candidates[index] + if desired > upper.BaseReward { + continue + } + delta := upper.BaseReward - lower.BaseReward + if delta <= 0 { + upper.Weight = luckyPPMScale + return []luckyCandidate{upper} + } + upperWeight := (desired - lower.BaseReward) * luckyPPMScale / delta + if upperWeight <= 0 { + lower.Weight = luckyPPMScale + return []luckyCandidate{lower} + } + if upperWeight >= luckyPPMScale { + upper.Weight = luckyPPMScale + return []luckyCandidate{upper} + } + lower.Weight = luckyPPMScale - upperWeight + upper.Weight = upperWeight + return []luckyCandidate{lower, upper} + } + last := candidates[len(candidates)-1] + last.Weight = luckyPPMScale + return []luckyCandidate{last} +} + +func luckyDedupeRewardCandidates(candidates []luckyCandidate) []luckyCandidate { + sort.Slice(candidates, func(i, j int) bool { + if candidates[i].BaseReward == candidates[j].BaseReward { + return candidates[i].MultiplierPPM < candidates[j].MultiplierPPM + } + return candidates[i].BaseReward < candidates[j].BaseReward + }) + out := candidates[:0] + for _, candidate := range candidates { + if len(out) > 0 && out[len(out)-1].BaseReward == candidate.BaseReward { + continue + } + out = append(out, candidate) + } + return out +} + +func luckyDesiredBaseReward(globalWindow, giftWindow luckyRTPWindow) int64 { + return minInt64(luckyWindowAveragePayout(globalWindow), luckyWindowAveragePayout(giftWindow)) +} + +func luckyWindowAveragePayout(window luckyRTPWindow) int64 { + remaining := window.remainingPayout() + if remaining <= 0 { + return 0 + } + draws := window.remainingDraws() + if draws <= 0 { + return remaining + } + return remaining / draws +} + func luckySplitWeighted(amount int64, config domain.Config) (int64, int64, int64) { platform := amount * config.PlatformPoolWeightPPM / luckyPPMScale room := amount * config.RoomPoolWeightPPM / luckyPPMScale @@ -1286,6 +1384,39 @@ func luckyCounterKey(scopeType, windowType string) string { return scopeType + ":" + windowType } +func luckyPoolID(poolID string) string { + poolID = strings.TrimSpace(poolID) + if poolID == "" { + return domain.DefaultPoolID + } + return poolID +} + +func luckyPoolScopedID(poolID, scopeID string) string { + return luckyPoolID(poolID) + ":" + strings.TrimSpace(scopeID) +} + +func luckyMultiplierPPMsFromTiers(tiers []domain.Tier, giftPrice int64) []int64 { + seen := map[int64]bool{} + out := make([]int64, 0, len(tiers)) + for _, tier := range tiers { + multiplier := tier.MultiplierPPM + if multiplier == 0 && tier.RewardCoins > 0 && giftPrice > 0 { + multiplier = tier.RewardCoins * luckyPPMScale / giftPrice + } + if multiplier < 0 || seen[multiplier] { + continue + } + seen[multiplier] = true + out = append(out, multiplier) + } + if !seen[0] { + out = append(out, 0) + } + sort.Slice(out, func(i, j int) bool { return out[i] < out[j] }) + return out +} + func luckyHasHighWater(reward int64, globalWindow, giftWindow luckyRTPWindow, poolCapacity int64, config domain.Config) bool { if globalWindow.highWaterHeadroom(config.GiftPrice) < reward || giftWindow.highWaterHeadroom(config.GiftPrice) < reward { return false diff --git a/services/activity-service/internal/storage/mysql/lucky_gift_repository_test.go b/services/activity-service/internal/storage/mysql/lucky_gift_repository_test.go index b69f8b05..a18c8de3 100644 --- a/services/activity-service/internal/storage/mysql/lucky_gift_repository_test.go +++ b/services/activity-service/internal/storage/mysql/lucky_gift_repository_test.go @@ -8,7 +8,8 @@ import ( func TestLuckyRuntimeConfigScalesTierRewardsByActualSpend(t *testing.T) { config := domain.Config{ - GiftID: domain.GlobalConfigGiftID, + GiftID: domain.DefaultPoolID, + PoolID: domain.DefaultPoolID, GiftPrice: 500, MaxSinglePayout: 250_000, UserHourlyPayoutCap: 500_000, @@ -56,3 +57,24 @@ func TestLuckyRTPWindowAddsCurrentSpendToTarget(t *testing.T) { t.Fatalf("expected carry to keep fractional target, got %d", next.CarryPPM) } } + +func TestLuckyAutoWeightedBaseCandidatesFollowsWindowAverage(t *testing.T) { + candidates := []luckyCandidate{ + {TierID: "none", BaseReward: 0}, + {TierID: "half", BaseReward: 500}, + {TierID: "one", BaseReward: 1000}, + } + window := luckyRTPWindow{ControlDraws: 100, TargetPayoutCoins: 95_000} + + weighted := luckyAutoWeightedBaseCandidates(candidates, 0, window, window) + + if len(weighted) != 2 { + t.Fatalf("expected two generated probability points, got %#v", weighted) + } + if weighted[0].TierID != "half" || weighted[0].Weight != 100_000 { + t.Fatalf("expected 0.5x to carry 10%% probability, got %#v", weighted[0]) + } + if weighted[1].TierID != "one" || weighted[1].Weight != 900_000 { + t.Fatalf("expected 1x to carry 90%% probability, got %#v", weighted[1]) + } +} diff --git a/services/activity-service/internal/transport/grpc/lucky_gift_server.go b/services/activity-service/internal/transport/grpc/lucky_gift_server.go index 6c10fca9..3c912887 100644 --- a/services/activity-service/internal/transport/grpc/lucky_gift_server.go +++ b/services/activity-service/internal/transport/grpc/lucky_gift_server.go @@ -24,6 +24,7 @@ func NewLuckyGiftServer(svc *service.Service) *LuckyGiftServer { func (s *LuckyGiftServer) CheckLuckyGift(ctx context.Context, req *activityv1.CheckLuckyGiftRequest) (*activityv1.CheckLuckyGiftResponse, error) { ctx = appcode.WithContext(ctx, req.GetMeta().GetAppCode()) result, err := s.svc.Check(ctx, domain.CheckCommand{ + PoolID: req.GetPoolId(), GiftID: req.GetGiftId(), UserID: req.GetUserId(), RoomID: req.GetRoomId(), @@ -34,6 +35,7 @@ func (s *LuckyGiftServer) CheckLuckyGift(ctx context.Context, req *activityv1.Ch return &activityv1.CheckLuckyGiftResponse{ Enabled: result.Enabled, Reason: result.Reason, + PoolId: result.PoolID, GiftId: result.GiftID, GiftPrice: result.GiftPrice, RuleVersion: result.RuleVersion, @@ -47,6 +49,7 @@ func (s *LuckyGiftServer) ExecuteLuckyGiftDraw(ctx context.Context, req *activit ctx = appcode.WithContext(ctx, meta.GetMeta().GetAppCode()) result, err := s.svc.Draw(ctx, domain.DrawCommand{ CommandID: meta.GetCommandId(), + PoolID: meta.GetPoolId(), UserID: meta.GetUserId(), DeviceID: meta.GetDeviceId(), RoomID: meta.GetRoomId(), @@ -74,7 +77,7 @@ func NewAdminLuckyGiftServer(svc *service.Service) *AdminLuckyGiftServer { func (s *AdminLuckyGiftServer) GetLuckyGiftConfig(ctx context.Context, req *activityv1.GetLuckyGiftConfigRequest) (*activityv1.GetLuckyGiftConfigResponse, error) { ctx = appcode.WithContext(ctx, req.GetMeta().GetAppCode()) - config, err := s.svc.GetConfig(ctx) + config, err := s.svc.GetConfig(ctx, req.GetPoolId()) if err != nil { return nil, xerr.ToGRPCError(err) } @@ -96,6 +99,7 @@ func (s *AdminLuckyGiftServer) ListLuckyGiftDraws(ctx context.Context, req *acti ctx = appcode.WithContext(ctx, req.GetMeta().GetAppCode()) draws, total, err := s.svc.ListDraws(ctx, domain.DrawQuery{ GiftID: req.GetGiftId(), + PoolID: req.GetPoolId(), UserID: req.GetUserId(), RoomID: req.GetRoomId(), Status: req.GetStatus(), @@ -122,6 +126,7 @@ func luckyConfigFromProto(config *activityv1.LuckyGiftConfig) domain.Config { Pool: tier.GetPool(), TierID: tier.GetTierId(), RewardCoins: tier.GetRewardCoins(), + MultiplierPPM: tier.GetMultiplierPpm(), Weight: tier.GetWeight(), HighWaterOnly: tier.GetHighWaterOnly(), Enabled: tier.GetEnabled(), @@ -130,6 +135,7 @@ func luckyConfigFromProto(config *activityv1.LuckyGiftConfig) domain.Config { return domain.Config{ AppCode: config.GetAppCode(), GiftID: config.GetGiftId(), + PoolID: config.GetPoolId(), Enabled: config.GetEnabled(), RuleVersion: config.GetRuleVersion(), GiftPrice: config.GetGiftPrice(), @@ -162,6 +168,7 @@ func luckyConfigFromProto(config *activityv1.LuckyGiftConfig) domain.Config { ActivityBudget: config.GetActivityBudget(), ActivityDailyLimit: config.GetActivityDailyLimit(), LargeTierEnabled: config.GetLargeTierEnabled(), + MultiplierPPMs: append([]int64(nil), config.GetMultiplierPpms()...), Tiers: tiers, UpdatedByAdminID: config.GetUpdatedByAdminId(), CreatedAtMS: config.GetCreatedAtMs(), @@ -176,6 +183,7 @@ func luckyConfigToProto(config domain.Config) *activityv1.LuckyGiftConfig { Pool: tier.Pool, TierId: tier.TierID, RewardCoins: tier.RewardCoins, + MultiplierPpm: tier.MultiplierPPM, Weight: tier.Weight, HighWaterOnly: tier.HighWaterOnly, Enabled: tier.Enabled, @@ -184,6 +192,7 @@ func luckyConfigToProto(config domain.Config) *activityv1.LuckyGiftConfig { return &activityv1.LuckyGiftConfig{ AppCode: config.AppCode, GiftId: config.GiftID, + PoolId: config.PoolID, Enabled: config.Enabled, RuleVersion: config.RuleVersion, GiftPrice: config.GiftPrice, @@ -216,6 +225,7 @@ func luckyConfigToProto(config domain.Config) *activityv1.LuckyGiftConfig { ActivityBudget: config.ActivityBudget, ActivityDailyLimit: config.ActivityDailyLimit, LargeTierEnabled: config.LargeTierEnabled, + MultiplierPpms: append([]int64(nil), config.MultiplierPPMs...), Tiers: tiers, UpdatedByAdminId: config.UpdatedByAdminID, CreatedAtMs: config.CreatedAtMS, @@ -227,6 +237,7 @@ func luckyDrawResultToProto(result domain.DrawResult) *activityv1.LuckyGiftDrawR return &activityv1.LuckyGiftDrawResult{ DrawId: result.DrawID, CommandId: result.CommandID, + PoolId: result.PoolID, GiftId: result.GiftID, RuleVersion: result.RuleVersion, ExperiencePool: result.ExperiencePool, diff --git a/services/game-service/deploy/mysql/initdb/001_game_service.sql b/services/game-service/deploy/mysql/initdb/001_game_service.sql index 816401fd..56467c33 100644 --- a/services/game-service/deploy/mysql/initdb/001_game_service.sql +++ b/services/game-service/deploy/mysql/initdb/001_game_service.sql @@ -12,6 +12,7 @@ CREATE TABLE IF NOT EXISTS game_platforms ( platform_name VARCHAR(128) NOT NULL COMMENT '平台名称', status VARCHAR(32) NOT NULL COMMENT '业务状态', api_base_url VARCHAR(512) NOT NULL DEFAULT '' COMMENT 'API 基准 URL', + adapter_type VARCHAR(64) NOT NULL DEFAULT 'demo' COMMENT '适配器类型', callback_secret_ciphertext VARCHAR(1024) NOT NULL DEFAULT '' COMMENT '回调密钥密文', callback_ip_whitelist JSON NULL COMMENT '回调IP白名单', adapter_config JSON NULL COMMENT '适配器配置', diff --git a/services/game-service/internal/domain/game/game.go b/services/game-service/internal/domain/game/game.go index 9829d356..c2bc81b2 100644 --- a/services/game-service/internal/domain/game/game.go +++ b/services/game-service/internal/domain/game/game.go @@ -10,11 +10,17 @@ const ( LaunchModeH5Popup = "h5_popup" SessionActive = "active" + AdapterDemo = "demo" + AdapterYomiV4 = "yomi_v4" + AdapterLeaderCCV1 = "leadercc_v1" + OrderStatusWalletApplying = "wallet_applying" OrderStatusSucceeded = "succeeded" OrderStatusFailed = "failed" OrderStatusConflict = "conflict" + RepairStatusLogged = "logged" + LevelEventStatusPending = "pending" LevelEventStatusRunning = "running" LevelEventStatusDelivered = "delivered" @@ -23,14 +29,19 @@ const ( // Platform 是第三方游戏平台配置事实。 type Platform struct { - AppCode string - PlatformCode string - PlatformName string - Status string - APIBaseURL string - SortOrder int32 - CreatedAtMS int64 - UpdatedAtMS int64 + AppCode string + PlatformCode string + PlatformName string + Status string + APIBaseURL string + AdapterType string + CallbackSecretCiphertext string + CallbackSecretConfigured bool + CallbackIPWhitelist []string + AdapterConfigJSON string + SortOrder int32 + CreatedAtMS int64 + UpdatedAtMS int64 } // CatalogItem 是内部稳定 game_id 到第三方 provider_game_id 的映射。 @@ -73,9 +84,13 @@ type AppGame struct { // LaunchableGame 汇总启动时需要的平台和目录配置。 type LaunchableGame struct { CatalogItem - PlatformName string - PlatformStatus string - APIBaseURL string + PlatformName string + PlatformStatus string + APIBaseURL string + AdapterType string + CallbackSecretCiphertext string + CallbackIPWhitelist []string + AdapterConfigJSON string } // LaunchSession 是 H5 启动会话持久事实,token 只保存 hash。 @@ -154,3 +169,19 @@ type CallbackLog struct { Status string CreatedAtMS int64 } + +// RepairOrder 是三方补单请求的最小审计事实;首版可只落库不改账。 +type RepairOrder struct { + AppCode string + RepairID string + PlatformCode string + ProviderOrderID string + Reason string + Status string + OperatorAdminID int64 + Attempts int32 + NextRunAtMS int64 + LastErrorCode string + CreatedAtMS int64 + UpdatedAtMS int64 +} diff --git a/services/game-service/internal/service/game/leadercc_adapter.go b/services/game-service/internal/service/game/leadercc_adapter.go new file mode 100644 index 00000000..dd51a054 --- /dev/null +++ b/services/game-service/internal/service/game/leadercc_adapter.go @@ -0,0 +1,283 @@ +package game + +import ( + "context" + "crypto/md5" + "encoding/hex" + "encoding/json" + "strconv" + "strings" + "time" + + gamev1 "hyapp.local/api/proto/game/v1" + "hyapp/pkg/xerr" + gamedomain "hyapp/services/game-service/internal/domain/game" +) + +const ( + leaderccCodeOK = 0 + leaderccCodeTimeout = 4000 + leaderccCodeInsufficientBalance = 4004 + leaderccCodeInvalidArgument = 4005 + leaderccCodeTokenInvalid = 10001 + leaderccCodeRiskRejected = 10002 + leaderccCodeDuplicatedOrder = 10003 + leaderccCodeSignatureInvalid = 10004 + + leaderccDefaultTokenTTL = 24 * time.Hour +) + +type leaderccAdapterConfig struct { + UIDMode string `json:"uid_mode"` + DefaultLang string `json:"default_lang"` + TokenTTLSeconds int64 `json:"token_ttl_seconds"` +} + +func leaderccConfigFromPlatform(value any) leaderccAdapterConfig { + var raw string + switch typed := value.(type) { + case gamedomain.Platform: + raw = typed.AdapterConfigJSON + case gamedomain.LaunchableGame: + raw = typed.AdapterConfigJSON + } + var config leaderccAdapterConfig + _ = json.Unmarshal([]byte(strings.TrimSpace(raw)), &config) + config.UIDMode = strings.ToLower(strings.TrimSpace(config.UIDMode)) + config.DefaultLang = strings.TrimSpace(config.DefaultLang) + return config +} + +func (c leaderccAdapterConfig) TokenTTL() time.Duration { + if c.TokenTTLSeconds > 0 { + return time.Duration(c.TokenTTLSeconds) * time.Second + } + return leaderccDefaultTokenTTL +} + +func (s *Service) handleLeaderCCOperation(ctx context.Context, app string, platform gamedomain.Platform, req *gamev1.CallbackRequest, operation string, requestHash string) ([]byte, string, string, bool, string, error) { + if !callbackIPAllowed(req, platform.CallbackIPWhitelist) { + raw, contentType := leaderccJSON(leaderccCodeRiskRejected, "ip forbidden", map[string]any{}) + return raw, contentType, strconv.Itoa(leaderccCodeRiskRejected), false, "", nil + } + switch operation { + case "userinfo", "user_info", "get_user_info": + return s.handleLeaderCCUserInfo(ctx, app, platform, req) + case "change_coin", "change_balance", "update_coin": + return s.handleLeaderCCChangeCoin(ctx, app, platform, req, requestHash) + case "repair_order", "repair", "repair_coin": + return s.handleLeaderCCRepairOrder(ctx, app, platform, req) + default: + raw, contentType := leaderccJSON(leaderccCodeOK, "", map[string]any{}) + return raw, contentType, strconv.Itoa(leaderccCodeOK), false, "", nil + } +} + +func (s *Service) handleLeaderCCUserInfo(ctx context.Context, app string, platform gamedomain.Platform, req *gamev1.CallbackRequest) ([]byte, string, string, bool, string, error) { + var body struct { + GameID string `json:"gameId"` + UID string `json:"uid"` + Token string `json:"token"` + RoomID string `json:"roomId"` + Sign string `json:"sign"` + } + if err := decodeLeaderCCJSON(req.GetRawBody(), &body); err != nil { + return leaderccAdapterError(leaderccCodeInvalidArgument, "invalid payload", false, "") + } + if !leaderccSignValid(body.Sign, platform.CallbackSecretCiphertext, body.GameID, body.UID, body.Token, body.RoomID) { + return leaderccAdapterError(leaderccCodeSignatureInvalid, "signature invalid", false, "") + } + config := leaderccConfigFromPlatform(platform) + session, err := s.validLeaderCCSession(ctx, app, body.Token) + if err != nil || !leaderccSessionMatches(session, config, body.UID, body.GameID) { + return leaderccAdapterError(leaderccCodeTokenInvalid, "token invalid", true, "") + } + snapshot, err := s.yomiUserSnapshot(ctx, app, req, session.UserID) + if err != nil { + return leaderccAdapterError(leaderccCodeFromError(err), leaderccMessageFromError(err), true, "") + } + raw, contentType := leaderccJSON(leaderccCodeOK, "", map[string]any{ + "uid": body.UID, + "nickname": snapshot.Nickname, + "avatar": snapshot.Avatar, + "coin": snapshot.Balance, + }) + return raw, contentType, strconv.Itoa(leaderccCodeOK), true, "", nil +} + +func (s *Service) handleLeaderCCChangeCoin(ctx context.Context, app string, platform gamedomain.Platform, req *gamev1.CallbackRequest, requestHash string) ([]byte, string, string, bool, string, error) { + var body struct { + OrderID string `json:"orderId"` + GameID string `json:"gameId"` + RoundID string `json:"roundId"` + UID string `json:"uid"` + Coin int64 `json:"coin"` + Type int32 `json:"type"` + RewardType int32 `json:"rewardType"` + Token string `json:"token"` + WinID string `json:"winId"` + RoomID string `json:"roomId"` + Sign string `json:"sign"` + } + if err := decodeLeaderCCJSON(req.GetRawBody(), &body); err != nil { + return leaderccAdapterError(leaderccCodeInvalidArgument, "invalid payload", false, "") + } + providerOrderID := strings.TrimSpace(body.OrderID) + if !leaderccSignValid(body.Sign, platform.CallbackSecretCiphertext, body.OrderID, body.GameID, body.RoundID, body.UID, strconv.FormatInt(body.Coin, 10), strconv.FormatInt(int64(body.Type), 10), strconv.FormatInt(int64(body.RewardType), 10), body.Token, body.WinID, body.RoomID) { + return leaderccAdapterError(leaderccCodeSignatureInvalid, "signature invalid", false, providerOrderID) + } + config := leaderccConfigFromPlatform(platform) + session, err := s.validLeaderCCSession(ctx, app, body.Token) + if err != nil || !leaderccSessionMatches(session, config, body.UID, body.GameID) { + return leaderccAdapterError(leaderccCodeTokenInvalid, "token invalid", true, providerOrderID) + } + opType := leaderccOpType(body.Type) + if providerOrderID == "" || opType == "" || body.Coin < 0 { + return leaderccAdapterError(leaderccCodeInvalidArgument, "invalid payload", true, providerOrderID) + } + result, err := s.applyYomiCoinChange(ctx, app, req, session, providerOrderID, body.RoundID, opType, body.Coin, body.RoomID, requestHash) + if err != nil { + code := leaderccCodeFromError(err) + message := leaderccMessageFromError(err) + balance := s.bestEffortCoinBalance(ctx, app, req.GetMeta().GetRequestId(), session.UserID) + raw, contentType := leaderccJSON(code, message, map[string]any{"coin": balance}) + return raw, contentType, strconv.Itoa(code), true, providerOrderID, nil + } + raw, contentType := leaderccJSON(leaderccCodeOK, "", map[string]any{"coin": result.BalanceAfter}) + return raw, contentType, strconv.Itoa(leaderccCodeOK), true, providerOrderID, nil +} + +func (s *Service) handleLeaderCCRepairOrder(ctx context.Context, app string, platform gamedomain.Platform, req *gamev1.CallbackRequest) ([]byte, string, string, bool, string, error) { + var body struct { + OrderID string `json:"orderId"` + GameID string `json:"gameId"` + RoundID string `json:"roundId"` + UID string `json:"uid"` + Coin int64 `json:"coin"` + RewardType int32 `json:"rewardType"` + WinID string `json:"winId"` + RoomID string `json:"roomId"` + Sign string `json:"sign"` + } + if err := decodeLeaderCCJSON(req.GetRawBody(), &body); err != nil { + return leaderccAdapterError(leaderccCodeInvalidArgument, "invalid payload", false, "") + } + providerOrderID := strings.TrimSpace(body.OrderID) + if !leaderccSignValid(body.Sign, platform.CallbackSecretCiphertext, body.OrderID, body.GameID, body.RoundID, body.UID, strconv.FormatInt(body.Coin, 10), strconv.FormatInt(int64(body.RewardType), 10), body.WinID, body.RoomID) { + return leaderccAdapterError(leaderccCodeSignatureInvalid, "signature invalid", false, providerOrderID) + } + if providerOrderID == "" || strings.TrimSpace(body.GameID) == "" || strings.TrimSpace(body.UID) == "" || body.Coin < 0 { + return leaderccAdapterError(leaderccCodeInvalidArgument, "invalid payload", true, providerOrderID) + } + _, _, err := s.repository.CreateRepairOrder(ctx, gamedomain.RepairOrder{ + AppCode: app, + RepairID: "grep_" + stableHash(app+"|"+platform.PlatformCode+"|"+providerOrderID), + PlatformCode: platform.PlatformCode, + ProviderOrderID: providerOrderID, + Reason: "leadercc_repair_order", + Status: gamedomain.RepairStatusLogged, + }) + if err != nil { + return leaderccAdapterError(leaderccCodeFromError(err), leaderccMessageFromError(err), true, providerOrderID) + } + raw, contentType := leaderccJSON(leaderccCodeOK, "", map[string]any{}) + return raw, contentType, strconv.Itoa(leaderccCodeOK), true, providerOrderID, nil +} + +func (s *Service) validLeaderCCSession(ctx context.Context, app string, token string) (gamedomain.LaunchSession, error) { + session, err := s.validYomiSession(ctx, app, token) + if err != nil { + return gamedomain.LaunchSession{}, err + } + return session, nil +} + +func leaderccSessionMatches(session gamedomain.LaunchSession, config leaderccAdapterConfig, uid string, gameID string) bool { + if strings.TrimSpace(gameID) != "" && strings.TrimSpace(gameID) != strings.TrimSpace(session.ProviderGameID) { + return false + } + return strings.TrimSpace(uid) == externalUserID(session, config.UIDMode) +} + +func leaderccOpType(value int32) string { + switch value { + case 1: + return "debit" + case 2: + return "credit" + default: + return "" + } +} + +func decodeLeaderCCJSON(raw []byte, out any) error { + decoder := json.NewDecoder(strings.NewReader(string(raw))) + decoder.UseNumber() + return decoder.Decode(out) +} + +func leaderccSignValid(actual string, key string, parts ...string) bool { + key = strings.TrimSpace(key) + if key == "" { + return false + } + joined := strings.Builder{} + for _, part := range parts { + joined.WriteString(strings.TrimSpace(part)) + } + joined.WriteString(key) + sum := md5.Sum([]byte(joined.String())) + expected := hex.EncodeToString(sum[:]) + return strings.EqualFold(strings.TrimSpace(actual), expected) +} + +func leaderccAdapterError(code int, message string, signatureValid bool, providerRequestID string) ([]byte, string, string, bool, string, error) { + raw, contentType := leaderccJSON(code, message, map[string]any{}) + return raw, contentType, strconv.Itoa(code), signatureValid, providerRequestID, nil +} + +func leaderccJSON(errorCode int, message string, data any) ([]byte, string) { + payload := map[string]any{"errorCode": errorCode} + if data != nil { + payload["data"] = data + } + if strings.TrimSpace(message) != "" { + payload["message"] = message + } + return jsonResponse(payload) +} + +func leaderccCodeFromError(err error) int { + switch { + case err == nil: + return leaderccCodeOK + case xerr.IsCode(err, xerr.InsufficientBalance): + return leaderccCodeInsufficientBalance + case xerr.IsCode(err, xerr.SessionExpired), xerr.IsCode(err, xerr.Unauthorized): + return leaderccCodeTokenInvalid + case xerr.IsCode(err, xerr.IdempotencyConflict): + return leaderccCodeDuplicatedOrder + case xerr.IsCode(err, xerr.Unavailable): + return leaderccCodeTimeout + default: + return leaderccCodeInvalidArgument + } +} + +func leaderccMessageFromError(err error) string { + switch leaderccCodeFromError(err) { + case leaderccCodeInsufficientBalance: + return "coin not enough" + case leaderccCodeTokenInvalid: + return "token invalid" + case leaderccCodeDuplicatedOrder: + return "duplicated order" + case leaderccCodeTimeout: + return "network timeout" + default: + if err != nil && strings.TrimSpace(xerr.MessageOf(err)) != "" { + return xerr.MessageOf(err) + } + return "invalid argument" + } +} diff --git a/services/game-service/internal/service/game/service.go b/services/game-service/internal/service/game/service.go index f522fda9..5f56778f 100644 --- a/services/game-service/internal/service/game/service.go +++ b/services/game-service/internal/service/game/service.go @@ -29,11 +29,14 @@ type Repository interface { Ping(ctx context.Context) error ListGames(ctx context.Context, query ListGamesQuery) ([]gamedomain.AppGame, error) GetLaunchableGame(ctx context.Context, appCode string, gameID string) (gamedomain.LaunchableGame, error) + GetPlatform(ctx context.Context, appCode string, platformCode string) (gamedomain.Platform, error) CreateLaunchSession(ctx context.Context, session gamedomain.LaunchSession) error + GetLaunchSessionByToken(ctx context.Context, appCode string, token string) (gamedomain.LaunchSession, error) InsertCallbackLog(ctx context.Context, log gamedomain.CallbackLog) error CreateGameOrder(ctx context.Context, order gamedomain.GameOrder) (gamedomain.GameOrder, bool, error) MarkOrderSucceeded(ctx context.Context, appCode string, orderID string, walletTransactionID string, balanceAfter int64, nowMs int64) error MarkOrderFailed(ctx context.Context, appCode string, orderID string, status string, code string, message string, nowMs int64) error + CreateRepairOrder(ctx context.Context, order gamedomain.RepairOrder) (gamedomain.RepairOrder, bool, error) ClaimPendingLevelEvents(ctx context.Context, workerID string, nowMS int64, lockTTL time.Duration, batchSize int) ([]gamedomain.LevelEventOutbox, error) MarkLevelEventDelivered(ctx context.Context, eventID string, nowMS int64) error MarkLevelEventFailed(ctx context.Context, eventID string, failureReason string, nextRetryAtMS int64, nowMS int64) error @@ -181,7 +184,13 @@ func (s *Service) LaunchGame(ctx context.Context, command LaunchCommand) (Launch now := s.now() sessionID := "game_sess_" + strconv.FormatInt(now.UnixMilli(), 10) + "_" + randomHex(6) token := "gt_" + randomHex(24) - expiresAt := now.Add(s.config.LaunchSessionTTL).UnixMilli() + sessionTTL := s.config.LaunchSessionTTL + if strings.EqualFold(game.AdapterType, gamedomain.AdapterYomiV4) { + sessionTTL = maxDuration(sessionTTL, yomiConfigFromPlatform(game).TokenTTL()) + } else if strings.EqualFold(game.AdapterType, gamedomain.AdapterLeaderCCV1) { + sessionTTL = maxDuration(sessionTTL, leaderccConfigFromPlatform(game).TokenTTL()) + } + expiresAt := now.Add(sessionTTL).UnixMilli() session := gamedomain.LaunchSession{ AppCode: command.AppCode, SessionID: sessionID, @@ -229,7 +238,24 @@ func (s *Service) HandleCallback(ctx context.Context, req *gamev1.CallbackReques requestHash := stableHashBytes(req.GetRawBody()) callbackID := "gcb_" + stableHash(fmt.Sprintf("%s|%s|%s|%d", app, req.GetPlatformCode(), requestHash, s.now().UnixNano())) - responseBody, contentType, statusCode, err := s.handleDemoOperation(ctx, app, req, operation, requestHash) + signatureValid := false + providerRequestID := "" + var responseBody []byte + var contentType string + var statusCode string + var err error + if platform, platformErr := s.repository.GetPlatform(ctx, app, req.GetPlatformCode()); platformErr == nil { + switch { + case strings.EqualFold(platform.AdapterType, gamedomain.AdapterYomiV4): + responseBody, contentType, statusCode, signatureValid, providerRequestID, err = s.handleYomiOperation(ctx, app, platform, req, operation, requestHash) + case strings.EqualFold(platform.AdapterType, gamedomain.AdapterLeaderCCV1): + responseBody, contentType, statusCode, signatureValid, providerRequestID, err = s.handleLeaderCCOperation(ctx, app, platform, req, operation, requestHash) + default: + responseBody, contentType, statusCode, err = s.handleDemoOperation(ctx, app, req, operation, requestHash) + } + } else { + responseBody, contentType, statusCode, err = s.handleDemoOperation(ctx, app, req, operation, requestHash) + } responseHash := stableHashBytes(responseBody) logStatus := "succeeded" if err != nil { @@ -239,17 +265,18 @@ func (s *Service) HandleCallback(ctx context.Context, req *gamev1.CallbackReques responseHash = stableHashBytes(responseBody) } _ = s.repository.InsertCallbackLog(ctx, gamedomain.CallbackLog{ - AppCode: app, - CallbackID: callbackID, - PlatformCode: strings.TrimSpace(req.GetPlatformCode()), - Operation: operation, - RequestID: req.GetMeta().GetRequestId(), - SignatureValid: false, - RequestHash: requestHash, - ResponseCode: statusCode, - ResponseBodyHash: responseHash, - Status: logStatus, - CreatedAtMS: s.now().UnixMilli(), + AppCode: app, + CallbackID: callbackID, + PlatformCode: strings.TrimSpace(req.GetPlatformCode()), + Operation: operation, + RequestID: req.GetMeta().GetRequestId(), + ProviderRequestID: providerRequestID, + SignatureValid: signatureValid, + RequestHash: requestHash, + ResponseCode: statusCode, + ResponseBodyHash: responseHash, + Status: logStatus, + CreatedAtMS: s.now().UnixMilli(), }) return responseBody, contentType, err } @@ -517,6 +544,42 @@ func buildLaunchURL(game gamedomain.LaunchableGame, session gamedomain.LaunchSes return "", xerr.New(xerr.InvalidArgument, "platform launch url is invalid") } query := parsed.Query() + if strings.EqualFold(game.AdapterType, gamedomain.AdapterYomiV4) { + config := yomiConfigFromPlatform(game) + lang := config.DefaultLang + if lang == "" { + lang = "en" + } + query.Set("gameUid", session.ProviderGameID) + query.Set("platAuthCode", token) + query.Set("platUserId", externalUserID(session, config.UIDMode)) + query.Set("lang", lang) + if config.GameLevelUID > 0 { + query.Set("gameLevelUid", strconv.FormatInt(config.GameLevelUID, 10)) + } else { + query.Set("gameLevelUid", "0") + } + if session.RoomID != "" { + query.Set("platRoomId", session.RoomID) + } + parsed.RawQuery = query.Encode() + return parsed.String(), nil + } + if strings.EqualFold(game.AdapterType, gamedomain.AdapterLeaderCCV1) { + config := leaderccConfigFromPlatform(game) + lang := config.DefaultLang + if lang == "" { + lang = "en" + } + query.Set("uid", externalUserID(session, config.UIDMode)) + query.Set("token", token) + query.Set("lang", lang) + if session.RoomID != "" { + query.Set("roomid", session.RoomID) + } + parsed.RawQuery = query.Encode() + return parsed.String(), nil + } query.Set("session_id", session.SessionID) query.Set("session_token", token) query.Set("game_id", session.GameID) @@ -530,6 +593,13 @@ func buildLaunchURL(game gamedomain.LaunchableGame, session gamedomain.LaunchSes return parsed.String(), nil } +func maxDuration(left time.Duration, right time.Duration) time.Duration { + if right > left { + return right + } + return left +} + func jsonResponse(value any) ([]byte, string) { body, err := json.Marshal(value) if err != nil { diff --git a/services/game-service/internal/service/game/service_test.go b/services/game-service/internal/service/game/service_test.go index 2aba7a15..9808a38e 100644 --- a/services/game-service/internal/service/game/service_test.go +++ b/services/game-service/internal/service/game/service_test.go @@ -2,6 +2,11 @@ package game import ( "context" + "crypto/aes" + "crypto/cipher" + "crypto/md5" + "encoding/base64" + "encoding/hex" "strings" "testing" "time" @@ -59,6 +64,285 @@ func TestLaunchGameCreatesSessionAndRejectsMaintenance(t *testing.T) { } } +func TestLaunchGameBuildsYomiAuthURL(t *testing.T) { + repo := &fakeRepository{ + launchable: gamedomain.LaunchableGame{ + CatalogItem: gamedomain.CatalogItem{ + AppCode: "lalu", + GameID: "yomi_fish_001", + PlatformCode: "yomi", + ProviderGameID: "33", + GameName: "Fish", + Status: gamedomain.StatusActive, + Orientation: "landscape", + }, + PlatformStatus: gamedomain.StatusActive, + APIBaseURL: "https://api-testv2.yomigame.games/gate/gateway/auth", + AdapterType: gamedomain.AdapterYomiV4, + AdapterConfigJSON: `{"default_lang":"en","token_ttl_seconds":86400,"uid_mode":"user_id"}`, + }, + } + svc := New(Config{LaunchSessionTTL: 15 * time.Minute}, repo, &fakeWallet{}, &fakeUser{}) + svc.now = func() time.Time { return time.UnixMilli(1700000000000) } + + result, err := svc.LaunchGame(context.Background(), LaunchCommand{ + AppCode: "lalu", + UserID: 42, + DisplayUserID: "420001", + GameID: "yomi_fish_001", + RoomID: "room_1", + }) + if err != nil { + t.Fatalf("LaunchGame failed: %v", err) + } + if result.ExpiresAtMS != 1700086400000 { + t.Fatalf("yomi launch must use adapter token ttl, got %+v", result) + } + for _, part := range []string{"gameUid=33", "platUserId=42", "lang=en", "gameLevelUid=0", "platRoomId=room_1", "platAuthCode=gt_"} { + if !strings.Contains(result.LaunchURL, part) { + t.Fatalf("yomi launch url missing %s: %s", part, result.LaunchURL) + } + } +} + +func TestLaunchGameBuildsLeaderCCURL(t *testing.T) { + repo := &fakeRepository{ + launchable: gamedomain.LaunchableGame{ + CatalogItem: gamedomain.CatalogItem{ + AppCode: "lalu", + GameID: "lingxian_rocket_001", + PlatformCode: "lingxian", + ProviderGameID: "101", + GameName: "Rocket", + Status: gamedomain.StatusActive, + Orientation: "portrait", + }, + PlatformStatus: gamedomain.StatusActive, + APIBaseURL: "https://games.leadercc.com/test/index.html", + AdapterType: gamedomain.AdapterLeaderCCV1, + AdapterConfigJSON: `{"default_lang":"zh-CN","token_ttl_seconds":86400,"uid_mode":"display_user_id"}`, + }, + } + svc := New(Config{LaunchSessionTTL: 15 * time.Minute}, repo, &fakeWallet{}, &fakeUser{}) + svc.now = func() time.Time { return time.UnixMilli(1700000000000) } + + result, err := svc.LaunchGame(context.Background(), LaunchCommand{ + AppCode: "lalu", + UserID: 42, + DisplayUserID: "420001", + GameID: "lingxian_rocket_001", + RoomID: "room_1", + }) + if err != nil { + t.Fatalf("LaunchGame failed: %v", err) + } + if result.ExpiresAtMS != 1700086400000 { + t.Fatalf("leadercc launch must use adapter token ttl, got %+v", result) + } + for _, part := range []string{"uid=420001", "token=gt_", "lang=zh-CN", "roomid=room_1"} { + if !strings.Contains(result.LaunchURL, part) { + t.Fatalf("leadercc launch url missing %s: %s", part, result.LaunchURL) + } + } +} + +func TestHandleLeaderCCUserInfoValidatesSignAndToken(t *testing.T) { + key := "leadercc-test-key" + token := "gt_lingxian_token" + repo := &fakeRepository{ + platform: gamedomain.Platform{ + PlatformCode: "lingxian", + AdapterType: gamedomain.AdapterLeaderCCV1, + CallbackSecretCiphertext: key, + CallbackIPWhitelist: []string{"3.1.174.194"}, + AdapterConfigJSON: `{"uid_mode":"display_user_id"}`, + }, + session: gamedomain.LaunchSession{ + AppCode: "lalu", + SessionID: "game_sess_lingxian", + UserID: 42, + DisplayUserID: "420001", + RoomID: "room_1", + PlatformCode: "lingxian", + GameID: "lingxian_rocket_001", + ProviderGameID: "101", + LaunchTokenHash: stableHash(token), + Status: gamedomain.SessionActive, + ExpiresAtMS: 1700086400000, + }, + } + wallet := &fakeWallet{balanceAfter: 1680} + svc := New(Config{}, repo, wallet, &fakeUser{}) + svc.now = func() time.Time { return time.UnixMilli(1700000000000) } + + sign := leaderccTestSign(key, "101", "420001", token, "room_1") + raw, contentType, err := svc.HandleCallback(context.Background(), &gamev1.CallbackRequest{ + Meta: &gamev1.RequestMeta{RequestId: "req-leadercc-user", AppCode: "lalu"}, + PlatformCode: "lingxian", + Operation: "userinfo", + RawBody: []byte(`{"gameId":"101","uid":"420001","token":"` + token + `","roomId":"room_1","sign":"` + sign + `"}`), + RemoteAddr: "3.1.174.194:443", + }) + if err != nil { + t.Fatalf("HandleCallback failed: %v", err) + } + if contentType != "application/json" || !strings.Contains(string(raw), `"errorCode":0`) || !strings.Contains(string(raw), `"coin":1680`) { + t.Fatalf("leadercc user response mismatch: %s", raw) + } +} + +func TestHandleLeaderCCChangeCoinAndRepairOrder(t *testing.T) { + key := "leadercc-test-key" + token := "gt_lingxian_token" + repo := &fakeRepository{ + platform: gamedomain.Platform{ + PlatformCode: "lingxian", + AdapterType: gamedomain.AdapterLeaderCCV1, + CallbackSecretCiphertext: key, + AdapterConfigJSON: `{"uid_mode":"user_id"}`, + }, + session: gamedomain.LaunchSession{ + AppCode: "lalu", + SessionID: "game_sess_lingxian", + UserID: 42, + DisplayUserID: "420001", + RoomID: "room_1", + PlatformCode: "lingxian", + GameID: "lingxian_rocket_001", + ProviderGameID: "101", + LaunchTokenHash: stableHash(token), + Status: gamedomain.SessionActive, + ExpiresAtMS: 1700086400000, + }, + } + wallet := &fakeWallet{balanceAfter: 1560} + svc := New(Config{}, repo, wallet, &fakeUser{}) + svc.now = func() time.Time { return time.UnixMilli(1700000000000) } + + changeSign := leaderccTestSign(key, "order_1", "101", "round_1", "42", "120", "1", "2", token, "", "room_1") + raw, _, err := svc.HandleCallback(context.Background(), &gamev1.CallbackRequest{ + Meta: &gamev1.RequestMeta{RequestId: "req-leadercc-change", AppCode: "lalu"}, + PlatformCode: "lingxian", + Operation: "change_coin", + RawBody: []byte(`{"orderId":"order_1","gameId":"101","roundId":"round_1","uid":"42","coin":120,"type":1,"rewardType":2,"token":"` + token + `","winId":"","roomId":"room_1","sign":"` + changeSign + `"}`), + RemoteAddr: "3.1.174.194:443", + }) + if err != nil { + t.Fatalf("HandleCallback failed: %v", err) + } + if !strings.Contains(string(raw), `"errorCode":0`) || !strings.Contains(string(raw), `"coin":1560`) { + t.Fatalf("leadercc change response mismatch: %s", raw) + } + if wallet.lastApply.GetCommandId() != "game:lingxian:order_1" || wallet.lastApply.GetOpType() != "debit" || wallet.lastApply.GetCoinAmount() != 120 { + t.Fatalf("wallet command mismatch: %+v", wallet.lastApply) + } + + repairSign := leaderccTestSign(key, "repair_1", "101", "round_2", "42", "50", "2", "", "room_1") + raw, _, err = svc.HandleCallback(context.Background(), &gamev1.CallbackRequest{ + Meta: &gamev1.RequestMeta{RequestId: "req-leadercc-repair", AppCode: "lalu"}, + PlatformCode: "lingxian", + Operation: "repair_order", + RawBody: []byte(`{"orderId":"repair_1","gameId":"101","roundId":"round_2","uid":"42","coin":50,"rewardType":2,"winId":"","roomId":"room_1","sign":"` + repairSign + `"}`), + RemoteAddr: "3.1.174.194:443", + }) + if err != nil { + t.Fatalf("HandleCallback failed: %v", err) + } + if !strings.Contains(string(raw), `"errorCode":0`) { + t.Fatalf("repair response mismatch: %s", raw) + } + if repo.repair.ProviderOrderID != "repair_1" || repo.repair.Reason != "leadercc_repair_order" { + t.Fatalf("repair order mismatch: %+v", repo.repair) + } +} + +func TestHandleYomiChangeBalanceDecryptsAndAppliesWallet(t *testing.T) { + secret := "12345678901234567890123456789012" + token := "gt_test_token" + repo := &fakeRepository{ + platform: gamedomain.Platform{ + PlatformCode: "yomi", + AdapterType: gamedomain.AdapterYomiV4, + CallbackSecretCiphertext: secret, + CallbackIPWhitelist: []string{"3.1.174.194"}, + AdapterConfigJSON: `{"uid_mode":"user_id"}`, + }, + session: gamedomain.LaunchSession{ + AppCode: "lalu", + SessionID: "game_sess_test", + UserID: 42, + DisplayUserID: "420001", + RoomID: "room_1", + PlatformCode: "yomi", + GameID: "yomi_fish_001", + ProviderGameID: "33", + LaunchTokenHash: stableHash(token), + Status: gamedomain.SessionActive, + ExpiresAtMS: 1700086400000, + }, + } + wallet := &fakeWallet{balanceAfter: 880} + svc := New(Config{}, repo, wallet, &fakeUser{}) + svc.now = func() time.Time { return time.UnixMilli(1700000000000) } + + rawBody := encryptYomiTestBody(t, secret, `{"requestId":9001,"platUserId":"42","platToken":"gt_test_token","gold":120,"gameUid":33,"gameLevelUid":0,"reasonType":1,"gameRoundId":88,"timestamp":1700000000,"platPayload":"","platRoomId":"room_1"}`) + raw, contentType, err := svc.HandleCallback(context.Background(), &gamev1.CallbackRequest{ + Meta: &gamev1.RequestMeta{RequestId: "req-yomi-change", AppCode: "lalu"}, + PlatformCode: "yomi", + Operation: "change_balance", + RawBody: rawBody, + RemoteAddr: "3.1.174.194:443", + }) + if err != nil { + t.Fatalf("HandleCallback failed: %v", err) + } + if contentType != "application/json" || !strings.Contains(string(raw), `"code":200`) || !strings.Contains(string(raw), `"balance":880`) { + t.Fatalf("yomi change response mismatch: %s", raw) + } + if wallet.lastApply.GetCommandId() != "game:yomi:9001" || wallet.lastApply.GetOpType() != "debit" || wallet.lastApply.GetCoinAmount() != 120 { + t.Fatalf("wallet command mismatch: %+v", wallet.lastApply) + } + if repo.order.ProviderOrderID != "9001" || repo.order.ProviderRoundID != "88" || repo.order.GameID != "yomi_fish_001" { + t.Fatalf("game order mismatch: %+v", repo.order) + } +} + +func TestHandleYomiRepairOrderOnlyLogs(t *testing.T) { + secret := "12345678901234567890123456789012" + repo := &fakeRepository{ + platform: gamedomain.Platform{ + PlatformCode: "yomi", + AdapterType: gamedomain.AdapterYomiV4, + CallbackSecretCiphertext: secret, + AdapterConfigJSON: `{}`, + }, + } + wallet := &fakeWallet{balanceAfter: 880} + svc := New(Config{}, repo, wallet, &fakeUser{}) + svc.now = func() time.Time { return time.UnixMilli(1700000000000) } + + rawBody := encryptYomiTestBody(t, secret, `{"requestId":9002,"platUserId":"42","gold":50,"gameUid":33,"gameRoundId":89,"rewardType":2,"winId":"","platRoomId":"room_1"}`) + raw, _, err := svc.HandleCallback(context.Background(), &gamev1.CallbackRequest{ + Meta: &gamev1.RequestMeta{RequestId: "req-yomi-repair", AppCode: "lalu"}, + PlatformCode: "yomi", + Operation: "repair_order", + RawBody: rawBody, + RemoteAddr: "3.1.174.194:443", + }) + if err != nil { + t.Fatalf("HandleCallback failed: %v", err) + } + if !strings.Contains(string(raw), `"code":200`) { + t.Fatalf("repair response mismatch: %s", raw) + } + if repo.repair.ProviderOrderID != "9002" || repo.repair.Status != gamedomain.RepairStatusLogged { + t.Fatalf("repair order mismatch: %+v", repo.repair) + } + if wallet.lastApply != nil { + t.Fatalf("repair order must not apply wallet change: %+v", wallet.lastApply) + } +} + func TestHandleCallbackChangeCoinUsesWalletAndOrderIdempotency(t *testing.T) { repo := &fakeRepository{ launchable: gamedomain.LaunchableGame{CatalogItem: gamedomain.CatalogItem{GameID: "demo_rocket_001", ProviderGameID: "rocket_001"}}, @@ -160,8 +444,10 @@ func TestProcessLevelEventOutboxBatchRelaysGameSpend(t *testing.T) { type fakeRepository struct { launchable gamedomain.LaunchableGame + platform gamedomain.Platform session gamedomain.LaunchSession order gamedomain.GameOrder + repair gamedomain.RepairOrder levelEvents []gamedomain.LevelEventOutbox deliveredEvents []string failedEvents []string @@ -174,10 +460,19 @@ func (f *fakeRepository) ListGames(context.Context, ListGamesQuery) ([]gamedomai func (f *fakeRepository) GetLaunchableGame(context.Context, string, string) (gamedomain.LaunchableGame, error) { return f.launchable, nil } +func (f *fakeRepository) GetPlatform(context.Context, string, string) (gamedomain.Platform, error) { + return f.platform, nil +} func (f *fakeRepository) CreateLaunchSession(_ context.Context, session gamedomain.LaunchSession) error { f.session = session return nil } +func (f *fakeRepository) GetLaunchSessionByToken(_ context.Context, _ string, token string) (gamedomain.LaunchSession, error) { + if f.session.LaunchTokenHash == stableHash(token) { + return f.session, nil + } + return gamedomain.LaunchSession{}, xerr.New(xerr.Unauthorized, "session token invalid") +} func (f *fakeRepository) InsertCallbackLog(context.Context, gamedomain.CallbackLog) error { return nil } func (f *fakeRepository) CreateGameOrder(_ context.Context, order gamedomain.GameOrder) (gamedomain.GameOrder, bool, error) { if f.order.OrderID != "" { @@ -218,6 +513,13 @@ func (f *fakeRepository) MarkOrderFailed(_ context.Context, _ string, orderID st f.order.UpdatedAtMS = nowMs return nil } +func (f *fakeRepository) CreateRepairOrder(_ context.Context, order gamedomain.RepairOrder) (gamedomain.RepairOrder, bool, error) { + if f.repair.RepairID != "" { + return f.repair, true, nil + } + f.repair = order + return order, false, nil +} func (f *fakeRepository) ClaimPendingLevelEvents(context.Context, string, int64, time.Duration, int) ([]gamedomain.LevelEventOutbox, error) { events := append([]gamedomain.LevelEventOutbox(nil), f.levelEvents...) f.levelEvents = nil @@ -290,3 +592,29 @@ func (f *fakeUser) GetUser(_ context.Context, req *userv1.GetUserRequest) (*user RegionId: 100, }}, nil } + +func encryptYomiTestBody(t *testing.T, secret string, plain string) []byte { + t.Helper() + block, err := aes.NewCipher([]byte(secret)) + if err != nil { + t.Fatalf("aes cipher failed: %v", err) + } + gcm, err := cipher.NewGCM(block) + if err != nil { + t.Fatalf("gcm failed: %v", err) + } + nonce := []byte("123456789012") + payload := append([]byte{}, nonce...) + payload = append(payload, gcm.Seal(nil, nonce, []byte(plain), nil)...) + return []byte(base64.URLEncoding.EncodeToString(payload)) +} + +func leaderccTestSign(key string, parts ...string) string { + builder := strings.Builder{} + for _, part := range parts { + builder.WriteString(strings.TrimSpace(part)) + } + builder.WriteString(strings.TrimSpace(key)) + sum := md5.Sum([]byte(builder.String())) + return hex.EncodeToString(sum[:]) +} diff --git a/services/game-service/internal/service/game/yomi_adapter.go b/services/game-service/internal/service/game/yomi_adapter.go new file mode 100644 index 00000000..ae7d3270 --- /dev/null +++ b/services/game-service/internal/service/game/yomi_adapter.go @@ -0,0 +1,532 @@ +package game + +import ( + "context" + "crypto/aes" + "crypto/cipher" + "encoding/base64" + "encoding/json" + "net" + "strconv" + "strings" + "time" + + gamev1 "hyapp.local/api/proto/game/v1" + userv1 "hyapp.local/api/proto/user/v1" + walletv1 "hyapp.local/api/proto/wallet/v1" + "hyapp/pkg/xerr" + gamedomain "hyapp/services/game-service/internal/domain/game" +) + +const ( + yomiCodeOK = 200 + yomiCodeSystemError = 9998 + yomiCodeInsufficientBalance = 10003 + yomiCodeGameMaintenance = 10004 + yomiCodeTokenInvalid = 10005 + yomiCodeSignatureInvalid = 10007 + yomiCodeGameNotFound = 10018 + yomiCodeUserNotFound = 10019 + yomiCodeIPForbidden = 10020 + + yomiDefaultTokenTTL = 24 * time.Hour +) + +type yomiAdapterConfig struct { + UIDMode string `json:"uid_mode"` + DefaultLang string `json:"default_lang"` + TokenTTLSeconds int64 `json:"token_ttl_seconds"` + GameLevelUID int64 `json:"game_level_uid"` +} + +func yomiConfigFromPlatform(value any) yomiAdapterConfig { + var raw string + switch typed := value.(type) { + case gamedomain.Platform: + raw = typed.AdapterConfigJSON + case gamedomain.LaunchableGame: + raw = typed.AdapterConfigJSON + } + var config yomiAdapterConfig + _ = json.Unmarshal([]byte(strings.TrimSpace(raw)), &config) + config.UIDMode = strings.ToLower(strings.TrimSpace(config.UIDMode)) + config.DefaultLang = strings.TrimSpace(config.DefaultLang) + return config +} + +func (c yomiAdapterConfig) TokenTTL() time.Duration { + if c.TokenTTLSeconds > 0 { + return time.Duration(c.TokenTTLSeconds) * time.Second + } + return yomiDefaultTokenTTL +} + +func externalUserID(session gamedomain.LaunchSession, uidMode string) string { + if strings.EqualFold(uidMode, "display_user_id") && strings.TrimSpace(session.DisplayUserID) != "" { + return strings.TrimSpace(session.DisplayUserID) + } + return strconv.FormatInt(session.UserID, 10) +} + +func (s *Service) handleYomiOperation(ctx context.Context, app string, platform gamedomain.Platform, req *gamev1.CallbackRequest, operation string, requestHash string) ([]byte, string, string, bool, string, error) { + if !callbackIPAllowed(req, platform.CallbackIPWhitelist) { + raw, contentType := yomiJSON(yomiCodeIPForbidden, "ip forbidden", map[string]any{}) + return raw, contentType, strconv.Itoa(yomiCodeIPForbidden), false, "", nil + } + body, err := decryptYomiBody(req.GetRawBody(), platform.CallbackSecretCiphertext) + if err != nil { + raw, contentType := yomiJSON(yomiCodeSignatureInvalid, "data signature invalid", map[string]any{}) + return raw, contentType, strconv.Itoa(yomiCodeSignatureInvalid), false, "", nil + } + + switch operation { + case "gettoken", "get_token", "api/gettoken": + return s.handleYomiGetToken(ctx, app, platform, req, body) + case "userinfo", "user_info", "api/userinfo": + return s.handleYomiUserInfo(ctx, app, platform, req, body) + case "change_balance", "changebalance", "api/change_balance": + return s.handleYomiChangeBalance(ctx, app, platform, req, body, requestHash) + case "repair_order", "repairorder", "api/repair_order": + return s.handleYomiRepairOrder(ctx, app, platform, body) + default: + raw, contentType := yomiJSON(yomiCodeOK, "success", map[string]any{}) + return raw, contentType, strconv.Itoa(yomiCodeOK), true, "", nil + } +} + +func (s *Service) handleYomiGetToken(ctx context.Context, app string, platform gamedomain.Platform, req *gamev1.CallbackRequest, raw []byte) ([]byte, string, string, bool, string, error) { + var body struct { + GameUID json.Number `json:"gameUid"` + Lang string `json:"lang"` + PlatAuthCode string `json:"platAuthCode"` + PlatUserID string `json:"platUserId"` + PlatPayload string `json:"platPayload"` + PlatRoomID string `json:"platRoomId"` + } + if err := decodeYomiJSON(raw, &body); err != nil { + return yomiAdapterError(yomiCodeSystemError, "invalid payload", true, "") + } + config := yomiConfigFromPlatform(platform) + session, err := s.validYomiSession(ctx, app, body.PlatAuthCode) + if err != nil || !yomiSessionMatches(session, config, body.PlatUserID, body.GameUID.String()) { + return yomiAdapterError(yomiCodeTokenInvalid, "token invalid", true, "") + } + snapshot, err := s.yomiUserSnapshot(ctx, app, req, session.UserID) + if err != nil { + return yomiAdapterError(yomiCodeFromError(err), yomiMessageFromError(err), true, "") + } + data := map[string]any{ + "platUserId": body.PlatUserID, + "platUserOpenId": session.DisplayUserID, + "nickname": snapshot.Nickname, + "avatar": snapshot.Avatar, + "balance": snapshot.Balance, + "platToken": body.PlatAuthCode, + "expireAt": session.ExpiresAtMS / 1000, + } + rawResp, contentType := yomiJSON(yomiCodeOK, "success", data) + return rawResp, contentType, strconv.Itoa(yomiCodeOK), true, "", nil +} + +func (s *Service) handleYomiUserInfo(ctx context.Context, app string, platform gamedomain.Platform, req *gamev1.CallbackRequest, raw []byte) ([]byte, string, string, bool, string, error) { + var body struct { + PlatUserID string `json:"platUserId"` + PlatToken string `json:"platToken"` + PlatPayload string `json:"platPayload"` + PlatRoomID string `json:"platRoomId"` + } + if err := decodeYomiJSON(raw, &body); err != nil { + return yomiAdapterError(yomiCodeSystemError, "invalid payload", true, "") + } + config := yomiConfigFromPlatform(platform) + session, err := s.validYomiSession(ctx, app, body.PlatToken) + if err != nil || !yomiSessionMatches(session, config, body.PlatUserID, "") { + return yomiAdapterError(yomiCodeTokenInvalid, "token invalid", true, "") + } + snapshot, err := s.yomiUserSnapshot(ctx, app, req, session.UserID) + if err != nil { + return yomiAdapterError(yomiCodeFromError(err), yomiMessageFromError(err), true, "") + } + data := map[string]any{ + "platUserId": body.PlatUserID, + "platUserOpenId": session.DisplayUserID, + "nickname": snapshot.Nickname, + "avatar": snapshot.Avatar, + "balance": snapshot.Balance, + } + rawResp, contentType := yomiJSON(yomiCodeOK, "success", data) + return rawResp, contentType, strconv.Itoa(yomiCodeOK), true, "", nil +} + +func (s *Service) handleYomiChangeBalance(ctx context.Context, app string, platform gamedomain.Platform, req *gamev1.CallbackRequest, raw []byte, requestHash string) ([]byte, string, string, bool, string, error) { + var body struct { + RequestID json.Number `json:"requestId"` + PlatUserID string `json:"platUserId"` + PlatToken string `json:"platToken"` + Gold int64 `json:"gold"` + GameUID json.Number `json:"gameUid"` + GameLevelUID json.Number `json:"gameLevelUid"` + ReasonType int32 `json:"reasonType"` + GameRoundID json.Number `json:"gameRoundId"` + Timestamp int64 `json:"timestamp"` + PlatPayload string `json:"platPayload"` + PlatRoomID string `json:"platRoomId"` + GameInfos []any `json:"game_infos"` + } + if err := decodeYomiJSON(raw, &body); err != nil { + return yomiAdapterError(yomiCodeSystemError, "invalid payload", true, "") + } + providerOrderID := strings.TrimSpace(body.RequestID.String()) + config := yomiConfigFromPlatform(platform) + session, err := s.validYomiSession(ctx, app, body.PlatToken) + if err != nil || !yomiSessionMatches(session, config, body.PlatUserID, body.GameUID.String()) { + return yomiAdapterError(yomiCodeTokenInvalid, "token invalid", true, providerOrderID) + } + opType := yomiOpType(body.ReasonType) + if providerOrderID == "" || opType == "" || body.Gold < 0 { + return yomiAdapterError(yomiCodeSystemError, "invalid payload", true, providerOrderID) + } + result, err := s.applyYomiCoinChange(ctx, app, req, session, providerOrderID, body.GameRoundID.String(), opType, body.Gold, body.PlatRoomID, requestHash) + if err != nil { + code := yomiCodeFromError(err) + message := yomiMessageFromError(err) + balance := s.bestEffortCoinBalance(ctx, app, req.GetMeta().GetRequestId(), session.UserID) + rawResp, contentType := yomiJSON(code, message, map[string]any{"balance": balance}) + return rawResp, contentType, strconv.Itoa(code), true, providerOrderID, nil + } + rawResp, contentType := yomiJSON(yomiCodeOK, "success", map[string]any{"balance": result.BalanceAfter}) + return rawResp, contentType, strconv.Itoa(yomiCodeOK), true, providerOrderID, nil +} + +func (s *Service) handleYomiRepairOrder(ctx context.Context, app string, platform gamedomain.Platform, raw []byte) ([]byte, string, string, bool, string, error) { + var body struct { + RequestID json.Number `json:"requestId"` + OrderID string `json:"orderId"` + PlatUserID string `json:"platUserId"` + Gold int64 `json:"gold"` + GameUID json.Number `json:"gameUid"` + GameRoundID json.Number `json:"gameRoundId"` + RewardType int32 `json:"rewardType"` + WinID string `json:"winId"` + PlatRoomID string `json:"platRoomId"` + } + if err := decodeYomiJSON(raw, &body); err != nil { + return yomiAdapterError(yomiCodeSystemError, "invalid payload", true, "") + } + providerOrderID := strings.TrimSpace(body.OrderID) + if providerOrderID == "" { + providerOrderID = strings.TrimSpace(body.RequestID.String()) + } + if providerOrderID == "" || strings.TrimSpace(body.PlatUserID) == "" || strings.TrimSpace(body.GameUID.String()) == "" || body.Gold < 0 { + return yomiAdapterError(yomiCodeSystemError, "invalid payload", true, providerOrderID) + } + _, _, err := s.repository.CreateRepairOrder(ctx, gamedomain.RepairOrder{ + AppCode: app, + RepairID: "grep_" + stableHash(app+"|"+platform.PlatformCode+"|"+providerOrderID), + PlatformCode: platform.PlatformCode, + ProviderOrderID: providerOrderID, + Reason: "yomi_repair_order", + Status: gamedomain.RepairStatusLogged, + }) + if err != nil { + return yomiAdapterError(yomiCodeFromError(err), yomiMessageFromError(err), true, providerOrderID) + } + rawResp, contentType := yomiJSON(yomiCodeOK, "success", map[string]any{}) + return rawResp, contentType, strconv.Itoa(yomiCodeOK), true, providerOrderID, nil +} + +type yomiCoinChangeResult struct { + BalanceAfter int64 +} + +func (s *Service) applyYomiCoinChange(ctx context.Context, app string, req *gamev1.CallbackRequest, session gamedomain.LaunchSession, providerOrderID string, providerRoundID string, opType string, amount int64, roomID string, requestHash string) (yomiCoinChangeResult, error) { + order := gamedomain.GameOrder{ + AppCode: app, + OrderID: "gord_" + stableHash(app+"|"+req.GetPlatformCode()+"|"+providerOrderID), + PlatformCode: strings.TrimSpace(req.GetPlatformCode()), + ProviderOrderID: providerOrderID, + ProviderRoundID: providerRoundID, + GameID: session.GameID, + ProviderGameID: session.ProviderGameID, + UserID: session.UserID, + RoomID: strings.TrimSpace(firstNonEmpty(roomID, session.RoomID)), + OpType: opType, + CoinAmount: amount, + Status: gamedomain.OrderStatusWalletApplying, + RequestHash: requestHash, + } + order, exists, err := s.repository.CreateGameOrder(ctx, order) + if err != nil { + return yomiCoinChangeResult{}, err + } + if exists && order.Status == gamedomain.OrderStatusSucceeded { + return yomiCoinChangeResult{BalanceAfter: order.WalletBalanceAfter}, nil + } + if amount == 0 { + balance := s.bestEffortCoinBalance(ctx, app, req.GetMeta().GetRequestId(), session.UserID) + if err := s.repository.MarkOrderSucceeded(ctx, app, order.OrderID, "", balance, s.now().UnixMilli()); err != nil { + return yomiCoinChangeResult{}, err + } + return yomiCoinChangeResult{BalanceAfter: balance}, nil + } + if s.wallet == nil { + return yomiCoinChangeResult{}, xerr.New(xerr.Unavailable, "wallet client is not configured") + } + walletResp, err := s.wallet.ApplyGameCoinChange(ctx, &walletv1.ApplyGameCoinChangeRequest{ + RequestId: req.GetMeta().GetRequestId(), + AppCode: app, + CommandId: "game:" + strings.TrimSpace(req.GetPlatformCode()) + ":" + providerOrderID, + UserId: session.UserID, + PlatformCode: strings.TrimSpace(req.GetPlatformCode()), + GameId: session.GameID, + ProviderOrderId: providerOrderID, + ProviderRoundId: providerRoundID, + OpType: opType, + CoinAmount: amount, + RoomId: firstNonEmpty(roomID, session.RoomID), + RequestHash: requestHash, + }) + nowMs := s.now().UnixMilli() + if err != nil { + status := gamedomain.OrderStatusFailed + if xerr.IsCode(err, xerr.IdempotencyConflict) { + status = gamedomain.OrderStatusConflict + } + _ = s.repository.MarkOrderFailed(ctx, app, order.OrderID, status, string(xerr.ReasonFromGRPC(err)), err.Error(), nowMs) + return yomiCoinChangeResult{}, err + } + if err := s.repository.MarkOrderSucceeded(ctx, app, order.OrderID, walletResp.GetWalletTransactionId(), walletResp.GetBalanceAfter(), nowMs); err != nil { + return yomiCoinChangeResult{}, err + } + return yomiCoinChangeResult{BalanceAfter: walletResp.GetBalanceAfter()}, nil +} + +type yomiUserSnapshot struct { + Nickname string + Avatar string + Balance int64 +} + +func (s *Service) yomiUserSnapshot(ctx context.Context, app string, req *gamev1.CallbackRequest, userID int64) (yomiUserSnapshot, error) { + if s.user == nil || s.wallet == nil { + return yomiUserSnapshot{}, xerr.New(xerr.Unavailable, "user or wallet client is not configured") + } + userResp, err := s.user.GetUser(ctx, &userv1.GetUserRequest{ + Meta: &userv1.RequestMeta{ + RequestId: req.GetMeta().GetRequestId(), + Caller: "game-service", + SentAtMs: s.now().UnixMilli(), + AppCode: app, + }, + UserId: userID, + }) + if err != nil { + return yomiUserSnapshot{}, err + } + return yomiUserSnapshot{ + Nickname: userResp.GetUser().GetUsername(), + Avatar: userResp.GetUser().GetAvatar(), + Balance: s.bestEffortCoinBalance(ctx, app, req.GetMeta().GetRequestId(), userID), + }, nil +} + +func (s *Service) bestEffortCoinBalance(ctx context.Context, app string, requestID string, userID int64) int64 { + if s.wallet == nil || userID <= 0 { + return 0 + } + resp, err := s.wallet.GetBalances(ctx, &walletv1.GetBalancesRequest{ + RequestId: requestID, + AppCode: app, + UserId: userID, + AssetTypes: []string{"COIN"}, + }) + if err != nil { + return 0 + } + for _, item := range resp.GetBalances() { + if item.GetAssetType() == "COIN" { + return item.GetAvailableAmount() + } + } + return 0 +} + +func (s *Service) validYomiSession(ctx context.Context, app string, token string) (gamedomain.LaunchSession, error) { + if strings.TrimSpace(token) == "" { + return gamedomain.LaunchSession{}, xerr.New(xerr.Unauthorized, "token invalid") + } + session, err := s.repository.GetLaunchSessionByToken(ctx, app, token) + if err != nil { + return gamedomain.LaunchSession{}, err + } + if session.Status != gamedomain.SessionActive || session.ExpiresAtMS <= s.now().UnixMilli() { + return gamedomain.LaunchSession{}, xerr.New(xerr.SessionExpired, "token expired") + } + return session, nil +} + +func yomiSessionMatches(session gamedomain.LaunchSession, config yomiAdapterConfig, platUserID string, gameUID string) bool { + if strings.TrimSpace(gameUID) != "" && strings.TrimSpace(gameUID) != strings.TrimSpace(session.ProviderGameID) { + return false + } + expectedUserID := externalUserID(session, config.UIDMode) + return strings.TrimSpace(platUserID) == expectedUserID +} + +func yomiOpType(reasonType int32) string { + switch reasonType { + case 1: + return "debit" + case 2: + return "credit" + default: + return "" + } +} + +func decodeYomiJSON(raw []byte, out any) error { + decoder := json.NewDecoder(strings.NewReader(string(raw))) + decoder.UseNumber() + return decoder.Decode(out) +} + +func decryptYomiBody(raw []byte, secret string) ([]byte, error) { + secret = strings.TrimSpace(secret) + if secret == "" { + return nil, xerr.New(xerr.InvalidArgument, "yomi secret is empty") + } + decoded, err := decodeYomiBase64(strings.TrimSpace(string(raw))) + if err != nil { + return nil, err + } + block, err := aes.NewCipher([]byte(secret)) + if err != nil { + return nil, err + } + gcm, err := cipher.NewGCM(block) + if err != nil { + return nil, err + } + if len(decoded) <= gcm.NonceSize() { + return nil, xerr.New(xerr.InvalidArgument, "yomi payload is too short") + } + nonce := decoded[:gcm.NonceSize()] + ciphertext := decoded[gcm.NonceSize():] + return gcm.Open(nil, nonce, ciphertext, nil) +} + +func decodeYomiBase64(value string) ([]byte, error) { + encodings := []*base64.Encoding{ + base64.URLEncoding, + base64.RawURLEncoding, + base64.StdEncoding, + base64.RawStdEncoding, + } + var lastErr error + for _, encoding := range encodings { + decoded, err := encoding.DecodeString(value) + if err == nil { + return decoded, nil + } + lastErr = err + } + return nil, lastErr +} + +func callbackIPAllowed(req *gamev1.CallbackRequest, whitelist []string) bool { + if len(whitelist) == 0 { + return true + } + ip := net.ParseIP(callbackRequestIP(req)) + if ip == nil { + return false + } + for _, raw := range whitelist { + value := strings.TrimSpace(raw) + if value == "" { + continue + } + if _, network, err := net.ParseCIDR(value); err == nil && network.Contains(ip) { + return true + } + if allowed := net.ParseIP(value); allowed != nil && allowed.Equal(ip) { + return true + } + } + return false +} + +func callbackRequestIP(req *gamev1.CallbackRequest) string { + for _, key := range []string{"X-Forwarded-For", "x-forwarded-for"} { + if raw := strings.TrimSpace(req.GetHeaders()[key]); raw != "" { + return strings.TrimSpace(strings.Split(raw, ",")[0]) + } + } + for _, key := range []string{"X-Real-IP", "x-real-ip"} { + if raw := strings.TrimSpace(req.GetHeaders()[key]); raw != "" { + return raw + } + } + host, _, err := net.SplitHostPort(strings.TrimSpace(req.GetRemoteAddr())) + if err == nil { + return host + } + return strings.TrimSpace(req.GetRemoteAddr()) +} + +func yomiAdapterError(code int, message string, signatureValid bool, providerRequestID string) ([]byte, string, string, bool, string, error) { + raw, contentType := yomiJSON(code, message, map[string]any{}) + return raw, contentType, strconv.Itoa(code), signatureValid, providerRequestID, nil +} + +func yomiJSON(code int, message string, data any) ([]byte, string) { + if strings.TrimSpace(message) == "" { + message = "success" + } + if data == nil { + data = map[string]any{} + } + return jsonResponse(map[string]any{"code": code, "message": message, "data": data}) +} + +func yomiCodeFromError(err error) int { + switch { + case err == nil: + return yomiCodeOK + case xerr.IsCode(err, xerr.InsufficientBalance): + return yomiCodeInsufficientBalance + case xerr.IsCode(err, xerr.SessionExpired), xerr.IsCode(err, xerr.Unauthorized): + return yomiCodeTokenInvalid + case xerr.IsCode(err, xerr.NotFound): + return yomiCodeUserNotFound + case xerr.IsCode(err, xerr.Conflict), xerr.IsCode(err, xerr.IdempotencyConflict): + return yomiCodeSystemError + default: + return yomiCodeSystemError + } +} + +func yomiMessageFromError(err error) string { + switch yomiCodeFromError(err) { + case yomiCodeInsufficientBalance: + return "gold not enough" + case yomiCodeTokenInvalid: + return "token invalid" + case yomiCodeUserNotFound: + return "user not found" + default: + if err != nil && strings.TrimSpace(xerr.MessageOf(err)) != "" { + return xerr.MessageOf(err) + } + return "system error" + } +} + +func firstNonEmpty(values ...string) string { + for _, value := range values { + if strings.TrimSpace(value) != "" { + return strings.TrimSpace(value) + } + } + return "" +} diff --git a/services/game-service/internal/storage/mysql/repository.go b/services/game-service/internal/storage/mysql/repository.go index 48948bae..07ee112c 100644 --- a/services/game-service/internal/storage/mysql/repository.go +++ b/services/game-service/internal/storage/mysql/repository.go @@ -2,7 +2,9 @@ package mysql import ( "context" + "crypto/sha256" "database/sql" + "encoding/hex" "encoding/json" "errors" "strings" @@ -64,6 +66,7 @@ func (r *Repository) Migrate(ctx context.Context) error { platform_name VARCHAR(128) NOT NULL, status VARCHAR(32) NOT NULL, api_base_url VARCHAR(512) NOT NULL DEFAULT '', + adapter_type VARCHAR(64) NOT NULL DEFAULT 'demo', callback_secret_ciphertext VARCHAR(1024) NOT NULL DEFAULT '', callback_ip_whitelist JSON NULL, adapter_config JSON NULL, @@ -217,9 +220,29 @@ func (r *Repository) Migrate(ctx context.Context) error { return err } } + if err := r.ensureColumn(ctx, "game_platforms", "adapter_type", "adapter_type VARCHAR(64) NOT NULL DEFAULT 'demo' AFTER api_base_url"); err != nil { + return err + } return r.seedDefaults(ctx) } +func (r *Repository) ensureColumn(ctx context.Context, tableName string, columnName string, columnDDL string) error { + var count int + if err := r.db.QueryRowContext(ctx, + `SELECT COUNT(*) + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND COLUMN_NAME = ?`, + tableName, columnName, + ).Scan(&count); err != nil { + return err + } + if count > 0 { + return nil + } + _, err := r.db.ExecContext(ctx, `ALTER TABLE `+tableName+` ADD COLUMN `+columnDDL) + return err +} + func (r *Repository) seedDefaults(ctx context.Context) error { if _, err := r.db.ExecContext(ctx, `INSERT INTO game_platforms (app_code, platform_code, platform_name, status, api_base_url, sort_order, created_at_ms, updated_at_ms) @@ -293,7 +316,9 @@ func (r *Repository) GetLaunchableGame(ctx context.Context, appCode string, game `SELECT c.app_code, c.game_id, c.platform_code, c.provider_game_id, c.game_name, c.category, c.icon_url, c.cover_url, c.launch_mode, c.orientation, c.min_coin, c.status, c.sort_order, COALESCE(CAST(c.tags AS CHAR), '[]'), c.created_at_ms, c.updated_at_ms, - p.platform_name, p.status, p.api_base_url + p.platform_name, p.status, p.api_base_url, COALESCE(p.adapter_type, 'demo'), + p.callback_secret_ciphertext, COALESCE(CAST(p.callback_ip_whitelist AS CHAR), '[]'), + COALESCE(CAST(p.adapter_config AS CHAR), '{}') FROM game_catalog c JOIN game_platforms p ON p.app_code = c.app_code AND p.platform_code = c.platform_code WHERE c.app_code = ? AND c.game_id = ?`, @@ -301,13 +326,34 @@ func (r *Repository) GetLaunchableGame(ctx context.Context, appCode string, game ) var item gamedomain.LaunchableGame var tagsJSON string - if err := row.Scan(&item.AppCode, &item.GameID, &item.PlatformCode, &item.ProviderGameID, &item.GameName, &item.Category, &item.IconURL, &item.CoverURL, &item.LaunchMode, &item.Orientation, &item.MinCoin, &item.Status, &item.SortOrder, &tagsJSON, &item.CreatedAtMS, &item.UpdatedAtMS, &item.PlatformName, &item.PlatformStatus, &item.APIBaseURL); err != nil { + var whitelistJSON string + if err := row.Scan(&item.AppCode, &item.GameID, &item.PlatformCode, &item.ProviderGameID, &item.GameName, &item.Category, &item.IconURL, &item.CoverURL, &item.LaunchMode, &item.Orientation, &item.MinCoin, &item.Status, &item.SortOrder, &tagsJSON, &item.CreatedAtMS, &item.UpdatedAtMS, &item.PlatformName, &item.PlatformStatus, &item.APIBaseURL, &item.AdapterType, &item.CallbackSecretCiphertext, &whitelistJSON, &item.AdapterConfigJSON); err != nil { if errors.Is(err, sql.ErrNoRows) { return gamedomain.LaunchableGame{}, xerr.New(xerr.NotFound, "game not found") } return gamedomain.LaunchableGame{}, err } _ = json.Unmarshal([]byte(tagsJSON), &item.Tags) + _ = json.Unmarshal([]byte(whitelistJSON), &item.CallbackIPWhitelist) + return item, nil +} + +func (r *Repository) GetPlatform(ctx context.Context, appCode string, platformCode string) (gamedomain.Platform, error) { + row := r.db.QueryRowContext(ctx, + `SELECT app_code, platform_code, platform_name, status, api_base_url, COALESCE(adapter_type, 'demo'), + callback_secret_ciphertext, COALESCE(CAST(callback_ip_whitelist AS CHAR), '[]'), + COALESCE(CAST(adapter_config AS CHAR), '{}'), sort_order, created_at_ms, updated_at_ms + FROM game_platforms + WHERE app_code = ? AND platform_code = ?`, + appcode.Normalize(appCode), strings.TrimSpace(platformCode), + ) + item, err := scanPlatform(row) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return gamedomain.Platform{}, xerr.New(xerr.NotFound, "game platform not found") + } + return gamedomain.Platform{}, err + } return item, nil } @@ -324,6 +370,24 @@ func (r *Repository) CreateLaunchSession(ctx context.Context, session gamedomain return err } +func (r *Repository) GetLaunchSessionByToken(ctx context.Context, appCode string, token string) (gamedomain.LaunchSession, error) { + row := r.db.QueryRowContext(ctx, + `SELECT app_code, session_id, user_id, display_user_id, room_id, scene, platform_code, game_id, + provider_game_id, launch_token_hash, status, expires_at_ms, created_at_ms, updated_at_ms + FROM game_launch_sessions + WHERE app_code = ? AND launch_token_hash = ?`, + appcode.Normalize(appCode), stableHash(strings.TrimSpace(token)), + ) + var item gamedomain.LaunchSession + if err := row.Scan(&item.AppCode, &item.SessionID, &item.UserID, &item.DisplayUserID, &item.RoomID, &item.Scene, &item.PlatformCode, &item.GameID, &item.ProviderGameID, &item.LaunchTokenHash, &item.Status, &item.ExpiresAtMS, &item.CreatedAtMS, &item.UpdatedAtMS); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return gamedomain.LaunchSession{}, xerr.New(xerr.Unauthorized, "game session token invalid") + } + return gamedomain.LaunchSession{}, err + } + return item, nil +} + func (r *Repository) InsertCallbackLog(ctx context.Context, log gamedomain.CallbackLog) error { _, err := r.db.ExecContext(ctx, `INSERT INTO game_callback_logs ( @@ -442,6 +506,50 @@ func (r *Repository) MarkOrderFailed(ctx context.Context, appCode string, orderI return err } +func (r *Repository) CreateRepairOrder(ctx context.Context, order gamedomain.RepairOrder) (gamedomain.RepairOrder, bool, error) { + nowMs := time.Now().UnixMilli() + order.AppCode = appcode.Normalize(order.AppCode) + if order.CreatedAtMS == 0 { + order.CreatedAtMS = nowMs + } + order.UpdatedAtMS = nowMs + if strings.TrimSpace(order.Status) == "" { + order.Status = gamedomain.RepairStatusLogged + } + result, err := r.db.ExecContext(ctx, + `INSERT IGNORE INTO game_repair_orders ( + app_code, repair_id, platform_code, provider_order_id, reason, status, operator_admin_id, + attempts, next_run_at_ms, last_error_code, created_at_ms, updated_at_ms + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + order.AppCode, order.RepairID, order.PlatformCode, order.ProviderOrderID, order.Reason, order.Status, + order.OperatorAdminID, order.Attempts, order.NextRunAtMS, order.LastErrorCode, order.CreatedAtMS, order.UpdatedAtMS, + ) + if err != nil { + return gamedomain.RepairOrder{}, false, err + } + affected, _ := result.RowsAffected() + if affected == 0 { + existing, err := r.getRepairOrderByProvider(ctx, order.AppCode, order.PlatformCode, order.ProviderOrderID) + return existing, true, err + } + return order, false, nil +} + +func (r *Repository) getRepairOrderByProvider(ctx context.Context, appCode string, platformCode string, providerOrderID string) (gamedomain.RepairOrder, error) { + row := r.db.QueryRowContext(ctx, + `SELECT app_code, repair_id, platform_code, provider_order_id, reason, status, operator_admin_id, + attempts, next_run_at_ms, last_error_code, created_at_ms, updated_at_ms + FROM game_repair_orders + WHERE app_code = ? AND platform_code = ? AND provider_order_id = ?`, + appcode.Normalize(appCode), strings.TrimSpace(platformCode), strings.TrimSpace(providerOrderID), + ) + var item gamedomain.RepairOrder + if err := row.Scan(&item.AppCode, &item.RepairID, &item.PlatformCode, &item.ProviderOrderID, &item.Reason, &item.Status, &item.OperatorAdminID, &item.Attempts, &item.NextRunAtMS, &item.LastErrorCode, &item.CreatedAtMS, &item.UpdatedAtMS); err != nil { + return gamedomain.RepairOrder{}, err + } + return item, nil +} + func queryOrderByIDForUpdate(ctx context.Context, tx *sql.Tx, appCode string, orderID string) (gamedomain.GameOrder, bool, error) { row := tx.QueryRowContext(ctx, `SELECT app_code, order_id, platform_code, provider_order_id, provider_round_id, game_id, provider_game_id, @@ -555,7 +663,9 @@ func (r *Repository) MarkLevelEventFailed(ctx context.Context, eventID string, f } func (r *Repository) ListPlatforms(ctx context.Context, appCode string, status string) ([]gamedomain.Platform, error) { - query := `SELECT app_code, platform_code, platform_name, status, api_base_url, sort_order, created_at_ms, updated_at_ms + query := `SELECT app_code, platform_code, platform_name, status, api_base_url, COALESCE(adapter_type, 'demo'), + callback_secret_ciphertext, COALESCE(CAST(callback_ip_whitelist AS CHAR), '[]'), + COALESCE(CAST(adapter_config AS CHAR), '{}'), sort_order, created_at_ms, updated_at_ms FROM game_platforms WHERE app_code = ?` args := []any{appcode.Normalize(appCode)} if strings.TrimSpace(status) != "" { @@ -570,8 +680,8 @@ func (r *Repository) ListPlatforms(ctx context.Context, appCode string, status s defer rows.Close() var items []gamedomain.Platform for rows.Next() { - var item gamedomain.Platform - if err := rows.Scan(&item.AppCode, &item.PlatformCode, &item.PlatformName, &item.Status, &item.APIBaseURL, &item.SortOrder, &item.CreatedAtMS, &item.UpdatedAtMS); err != nil { + item, err := scanPlatform(rows) + if err != nil { return nil, err } items = append(items, item) @@ -586,14 +696,29 @@ func (r *Repository) UpsertPlatform(ctx context.Context, platform gamedomain.Pla platform.CreatedAtMS = nowMs } platform.UpdatedAtMS = nowMs + whitelistJSON, _ := json.Marshal(platform.CallbackIPWhitelist) + adapterConfig := strings.TrimSpace(platform.AdapterConfigJSON) + if adapterConfig == "" { + adapterConfig = "{}" + } _, err := r.db.ExecContext(ctx, - `INSERT INTO game_platforms (app_code, platform_code, platform_name, status, api_base_url, sort_order, created_at_ms, updated_at_ms) - VALUES (?, ?, ?, ?, ?, ?, ?, ?) + `INSERT INTO game_platforms ( + app_code, platform_code, platform_name, status, api_base_url, adapter_type, + callback_secret_ciphertext, callback_ip_whitelist, adapter_config, + sort_order, created_at_ms, updated_at_ms + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON DUPLICATE KEY UPDATE platform_name = VALUES(platform_name), status = VALUES(status), - api_base_url = VALUES(api_base_url), sort_order = VALUES(sort_order), updated_at_ms = VALUES(updated_at_ms)`, - platform.AppCode, platform.PlatformCode, platform.PlatformName, platform.Status, platform.APIBaseURL, platform.SortOrder, platform.CreatedAtMS, platform.UpdatedAtMS, + api_base_url = VALUES(api_base_url), adapter_type = VALUES(adapter_type), + callback_secret_ciphertext = IF(VALUES(callback_secret_ciphertext) = '', callback_secret_ciphertext, VALUES(callback_secret_ciphertext)), + callback_ip_whitelist = VALUES(callback_ip_whitelist), adapter_config = VALUES(adapter_config), + sort_order = VALUES(sort_order), updated_at_ms = VALUES(updated_at_ms)`, + platform.AppCode, platform.PlatformCode, platform.PlatformName, platform.Status, platform.APIBaseURL, platform.AdapterType, + platform.CallbackSecretCiphertext, string(whitelistJSON), adapterConfig, platform.SortOrder, platform.CreatedAtMS, platform.UpdatedAtMS, ) - return platform, err + if err != nil { + return gamedomain.Platform{}, err + } + return r.GetPlatform(ctx, platform.AppCode, platform.PlatformCode) } func (r *Repository) ListCatalog(ctx context.Context, query gameservice.ListCatalogQuery) ([]gamedomain.CatalogItem, string, error) { @@ -708,6 +833,17 @@ func scanLevelEventOutbox(scanner catalogScanner) (gamedomain.LevelEventOutbox, return item, err } +func scanPlatform(scanner catalogScanner) (gamedomain.Platform, error) { + var item gamedomain.Platform + var whitelistJSON string + if err := scanner.Scan(&item.AppCode, &item.PlatformCode, &item.PlatformName, &item.Status, &item.APIBaseURL, &item.AdapterType, &item.CallbackSecretCiphertext, &whitelistJSON, &item.AdapterConfigJSON, &item.SortOrder, &item.CreatedAtMS, &item.UpdatedAtMS); err != nil { + return gamedomain.Platform{}, err + } + item.CallbackSecretConfigured = strings.TrimSpace(item.CallbackSecretCiphertext) != "" + _ = json.Unmarshal([]byte(whitelistJSON), &item.CallbackIPWhitelist) + return item, nil +} + func levelEventOutboxSelectSQL() string { return `SELECT app_code, event_id, order_id, user_id, game_id, provider_order_id, provider_round_id, coin_amount, wallet_transaction_id, @@ -733,3 +869,8 @@ func truncate(value string, max int) string { } return value[:max] } + +func stableHash(value string) string { + sum := sha256.Sum256([]byte(value)) + return hex.EncodeToString(sum[:]) +} diff --git a/services/game-service/internal/transport/grpc/server.go b/services/game-service/internal/transport/grpc/server.go index 3078d5ec..058608b2 100644 --- a/services/game-service/internal/transport/grpc/server.go +++ b/services/game-service/internal/transport/grpc/server.go @@ -188,9 +188,15 @@ func (unavailableRepository) ListGames(context.Context, gameservice.ListGamesQue func (unavailableRepository) GetLaunchableGame(context.Context, string, string) (gamedomain.LaunchableGame, error) { return gamedomain.LaunchableGame{}, xerr.New(xerr.Unavailable, "game repository is not configured") } +func (unavailableRepository) GetPlatform(context.Context, string, string) (gamedomain.Platform, error) { + return gamedomain.Platform{}, xerr.New(xerr.Unavailable, "game repository is not configured") +} func (unavailableRepository) CreateLaunchSession(context.Context, gamedomain.LaunchSession) error { return xerr.New(xerr.Unavailable, "game repository is not configured") } +func (unavailableRepository) GetLaunchSessionByToken(context.Context, string, string) (gamedomain.LaunchSession, error) { + return gamedomain.LaunchSession{}, xerr.New(xerr.Unavailable, "game repository is not configured") +} func (unavailableRepository) InsertCallbackLog(context.Context, gamedomain.CallbackLog) error { return xerr.New(xerr.Unavailable, "game repository is not configured") } @@ -203,6 +209,9 @@ func (unavailableRepository) MarkOrderSucceeded(context.Context, string, string, func (unavailableRepository) MarkOrderFailed(context.Context, string, string, string, string, string, int64) error { return xerr.New(xerr.Unavailable, "game repository is not configured") } +func (unavailableRepository) CreateRepairOrder(context.Context, gamedomain.RepairOrder) (gamedomain.RepairOrder, bool, error) { + return gamedomain.RepairOrder{}, false, xerr.New(xerr.Unavailable, "game repository is not configured") +} func (unavailableRepository) ClaimPendingLevelEvents(context.Context, string, int64, time.Duration, int) ([]gamedomain.LevelEventOutbox, error) { return nil, xerr.New(xerr.Unavailable, "game repository is not configured") } @@ -248,14 +257,18 @@ func appGameToProto(item gamedomain.AppGame) *gamev1.AppGame { func platformToProto(item gamedomain.Platform) *gamev1.GamePlatform { return &gamev1.GamePlatform{ - AppCode: item.AppCode, - PlatformCode: item.PlatformCode, - PlatformName: item.PlatformName, - Status: item.Status, - ApiBaseUrl: item.APIBaseURL, - SortOrder: item.SortOrder, - CreatedAtMs: item.CreatedAtMS, - UpdatedAtMs: item.UpdatedAtMS, + AppCode: item.AppCode, + PlatformCode: item.PlatformCode, + PlatformName: item.PlatformName, + Status: item.Status, + ApiBaseUrl: item.APIBaseURL, + SortOrder: item.SortOrder, + CreatedAtMs: item.CreatedAtMS, + UpdatedAtMs: item.UpdatedAtMS, + AdapterType: item.AdapterType, + CallbackSecretSet: item.CallbackSecretConfigured, + CallbackIpWhitelist: item.CallbackIPWhitelist, + AdapterConfigJson: item.AdapterConfigJSON, } } @@ -264,12 +277,17 @@ func platformFromProto(item *gamev1.GamePlatform) gamedomain.Platform { return gamedomain.Platform{} } return gamedomain.Platform{ - AppCode: item.GetAppCode(), - PlatformCode: item.GetPlatformCode(), - PlatformName: item.GetPlatformName(), - Status: item.GetStatus(), - APIBaseURL: item.GetApiBaseUrl(), - SortOrder: item.GetSortOrder(), + AppCode: item.GetAppCode(), + PlatformCode: item.GetPlatformCode(), + PlatformName: item.GetPlatformName(), + Status: item.GetStatus(), + APIBaseURL: item.GetApiBaseUrl(), + AdapterType: item.GetAdapterType(), + CallbackSecretCiphertext: item.GetCallbackSecret(), + CallbackSecretConfigured: item.GetCallbackSecretSet(), + CallbackIPWhitelist: item.GetCallbackIpWhitelist(), + AdapterConfigJSON: item.GetAdapterConfigJson(), + SortOrder: item.GetSortOrder(), } } @@ -321,9 +339,19 @@ func normalizePlatform(item gamedomain.Platform) (gamedomain.Platform, error) { item.PlatformName = strings.TrimSpace(item.PlatformName) item.Status = normalizeStatus(item.Status) item.APIBaseURL = strings.TrimSpace(item.APIBaseURL) + item.AdapterType = normalizeAdapterType(item.AdapterType) + item.CallbackSecretCiphertext = strings.TrimSpace(item.CallbackSecretCiphertext) + item.CallbackIPWhitelist = compactStrings(item.CallbackIPWhitelist) + item.AdapterConfigJSON = strings.TrimSpace(item.AdapterConfigJSON) + if item.AdapterConfigJSON == "" { + item.AdapterConfigJSON = "{}" + } if item.PlatformCode == "" || item.PlatformName == "" || item.Status == "" { return gamedomain.Platform{}, xerr.New(xerr.InvalidArgument, "platform is incomplete") } + if item.AdapterType == "" { + return gamedomain.Platform{}, xerr.New(xerr.InvalidArgument, "platform adapter_type is invalid") + } return item, nil } @@ -358,6 +386,36 @@ func normalizeStatus(status string) string { } } +func normalizeAdapterType(adapterType string) string { + switch strings.ToLower(strings.TrimSpace(adapterType)) { + case "", gamedomain.AdapterDemo: + return gamedomain.AdapterDemo + case gamedomain.AdapterYomiV4: + return gamedomain.AdapterYomiV4 + case gamedomain.AdapterLeaderCCV1: + return gamedomain.AdapterLeaderCCV1 + default: + return "" + } +} + +func compactStrings(values []string) []string { + out := make([]string, 0, len(values)) + seen := make(map[string]struct{}, len(values)) + for _, value := range values { + value = strings.TrimSpace(value) + if value == "" { + continue + } + if _, ok := seen[value]; ok { + continue + } + seen[value] = struct{}{} + out = append(out, value) + } + return out +} + func durationFromMillis(value int64) time.Duration { if value <= 0 { return 0 diff --git a/services/gateway-service/internal/transport/http/roomapi/room_handler.go b/services/gateway-service/internal/transport/http/roomapi/room_handler.go index 0f32a575..54fb65cb 100644 --- a/services/gateway-service/internal/transport/http/roomapi/room_handler.go +++ b/services/gateway-service/internal/transport/http/roomapi/room_handler.go @@ -1080,6 +1080,7 @@ func (h *Handler) sendGift(writer http.ResponseWriter, request *http.Request) { TargetUserIDs []int64 `json:"target_user_ids"` GiftID string `json:"gift_id"` GiftCount int32 `json:"gift_count"` + PoolID string `json:"pool_id"` } if !httpkit.Decode(writer, request, &body) { @@ -1102,6 +1103,7 @@ func (h *Handler) sendGift(writer http.ResponseWriter, request *http.Request) { TargetUserId: firstUserID(targetUserIDs), GiftId: body.GiftID, GiftCount: body.GiftCount, + PoolId: body.PoolID, }) httpkit.Write(writer, request, resp, err) } diff --git a/services/room-service/internal/room/command/command.go b/services/room-service/internal/room/command/command.go index f61b82a9..2f1886b7 100644 --- a/services/room-service/internal/room/command/command.go +++ b/services/room-service/internal/room/command/command.go @@ -326,6 +326,8 @@ type SendGift struct { TargetUserID int64 `json:"target_user_id"` // GiftID 是礼物配置标识,具体配置有效性当前由调用方和 wallet 链路保证。 GiftID string `json:"gift_id"` + // PoolID 是幸运礼物奖池标识;为空表示普通礼物或默认幸运奖池。 + PoolID string `json:"pool_id,omitempty"` // GiftCount 是礼物数量,必须为正数。 GiftCount int32 `json:"gift_count"` // BillingReceiptID 是 wallet-service 成功落账后的回执,用于排障和恢复审计。 diff --git a/services/room-service/internal/room/service/gift.go b/services/room-service/internal/room/service/gift.go index bf4804d9..6c2cbecc 100644 --- a/services/room-service/internal/room/service/gift.go +++ b/services/room-service/internal/room/service/gift.go @@ -27,6 +27,7 @@ func (s *Service) SendGift(ctx context.Context, req *roomv1.SendGiftRequest) (*r TargetType: normalizeGiftTargetType(req.GetTargetType()), TargetUserIDs: normalizeGiftTargetUserIDs(req.GetTargetUserId(), req.GetTargetUserIds()), GiftID: req.GetGiftId(), + PoolID: strings.TrimSpace(req.GetPoolId()), GiftCount: req.GetGiftCount(), } if cmd.TargetType == "" { @@ -116,6 +117,7 @@ func (s *Service) SendGift(ctx context.Context, req *roomv1.SendGiftRequest) (*r SenderUserId: cmd.ActorUserID(), TargetUserId: cmd.TargetUserID, GiftId: cmd.GiftID, + PoolId: cmd.PoolID, GiftCount: cmd.GiftCount, GiftValue: heatValue, BillingReceiptId: billing.GetBillingReceiptId(), @@ -179,6 +181,7 @@ func (s *Service) SendGift(ctx context.Context, req *roomv1.SendGiftRequest) (*r RoomVersion: current.Version, Attributes: map[string]string{ "gift_id": cmd.GiftID, + "pool_id": cmd.PoolID, "gift_count": fmt.Sprintf("%d", cmd.GiftCount), "billing_receipt_id": billing.GetBillingReceiptId(), "coin_spent": fmt.Sprintf("%d", settledCommand.CoinSpent), diff --git a/services/room-service/internal/room/service/room_treasure_test.go b/services/room-service/internal/room/service/room_treasure_test.go index 84443760..acc47c2e 100644 --- a/services/room-service/internal/room/service/room_treasure_test.go +++ b/services/room-service/internal/room/service/room_treasure_test.go @@ -207,7 +207,8 @@ func TestRoomTreasureOpenDueEarlyMessageDoesNotPoisonCommandID(t *testing.T) { t.Fatalf("due delayed message should open treasure after retry: %v", err) } info, err := svc.GetRoomTreasure(ctx, &roomv1.GetRoomTreasureRequest{ - RoomId: roomID, + RoomId: roomID, + ViewerUserId: ownerID, Meta: &roomv1.RequestMeta{ AppCode: appcode.Default, RequestId: "req-check",