游戏部署
This commit is contained in:
parent
36da9df0c7
commit
61f4e31b6a
@ -1,7 +1,7 @@
|
|||||||
// Code generated by protoc-gen-go. DO NOT EDIT.
|
// Code generated by protoc-gen-go. DO NOT EDIT.
|
||||||
// versions:
|
// versions:
|
||||||
// protoc-gen-go v1.36.11
|
// protoc-gen-go v1.36.11
|
||||||
// protoc v5.28.3
|
// protoc (unknown)
|
||||||
// source: proto/activity/v1/activity.proto
|
// source: proto/activity/v1/activity.proto
|
||||||
|
|
||||||
package activityv1
|
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"`
|
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"`
|
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"`
|
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
|
unknownFields protoimpl.UnknownFields
|
||||||
sizeCache protoimpl.SizeCache
|
sizeCache protoimpl.SizeCache
|
||||||
}
|
}
|
||||||
@ -8990,6 +8991,13 @@ func (x *LuckyGiftMeta) GetPaidAtMs() int64 {
|
|||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (x *LuckyGiftMeta) GetPoolId() string {
|
||||||
|
if x != nil {
|
||||||
|
return x.PoolId
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
type LuckyGiftTier struct {
|
type LuckyGiftTier struct {
|
||||||
state protoimpl.MessageState `protogen:"open.v1"`
|
state protoimpl.MessageState `protogen:"open.v1"`
|
||||||
Pool string `protobuf:"bytes,1,opt,name=pool,proto3" json:"pool,omitempty"`
|
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"`
|
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"`
|
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"`
|
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
|
unknownFields protoimpl.UnknownFields
|
||||||
sizeCache protoimpl.SizeCache
|
sizeCache protoimpl.SizeCache
|
||||||
}
|
}
|
||||||
@ -9074,6 +9083,13 @@ func (x *LuckyGiftTier) GetEnabled() bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (x *LuckyGiftTier) GetMultiplierPpm() int64 {
|
||||||
|
if x != nil {
|
||||||
|
return x.MultiplierPpm
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
type LuckyGiftConfig struct {
|
type LuckyGiftConfig struct {
|
||||||
state protoimpl.MessageState `protogen:"open.v1"`
|
state protoimpl.MessageState `protogen:"open.v1"`
|
||||||
AppCode string `protobuf:"bytes,1,opt,name=app_code,json=appCode,proto3" json:"app_code,omitempty"`
|
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"`
|
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"`
|
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"`
|
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
|
unknownFields protoimpl.UnknownFields
|
||||||
sizeCache protoimpl.SizeCache
|
sizeCache protoimpl.SizeCache
|
||||||
}
|
}
|
||||||
@ -9414,12 +9432,27 @@ func (x *LuckyGiftConfig) GetUpdatedAtMs() int64 {
|
|||||||
return 0
|
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 {
|
type CheckLuckyGiftRequest struct {
|
||||||
state protoimpl.MessageState `protogen:"open.v1"`
|
state protoimpl.MessageState `protogen:"open.v1"`
|
||||||
Meta *RequestMeta `protobuf:"bytes,1,opt,name=meta,proto3" json:"meta,omitempty"`
|
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"`
|
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"`
|
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"`
|
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
|
unknownFields protoimpl.UnknownFields
|
||||||
sizeCache protoimpl.SizeCache
|
sizeCache protoimpl.SizeCache
|
||||||
}
|
}
|
||||||
@ -9482,6 +9515,13 @@ func (x *CheckLuckyGiftRequest) GetGiftId() string {
|
|||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (x *CheckLuckyGiftRequest) GetPoolId() string {
|
||||||
|
if x != nil {
|
||||||
|
return x.PoolId
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
type CheckLuckyGiftResponse struct {
|
type CheckLuckyGiftResponse struct {
|
||||||
state protoimpl.MessageState `protogen:"open.v1"`
|
state protoimpl.MessageState `protogen:"open.v1"`
|
||||||
Enabled bool `protobuf:"varint,1,opt,name=enabled,proto3" json:"enabled,omitempty"`
|
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"`
|
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"`
|
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"`
|
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
|
unknownFields protoimpl.UnknownFields
|
||||||
sizeCache protoimpl.SizeCache
|
sizeCache protoimpl.SizeCache
|
||||||
}
|
}
|
||||||
@ -9574,6 +9615,13 @@ func (x *CheckLuckyGiftResponse) GetExperiencePool() string {
|
|||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (x *CheckLuckyGiftResponse) GetPoolId() string {
|
||||||
|
if x != nil {
|
||||||
|
return x.PoolId
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
type LuckyGiftDrawResult struct {
|
type LuckyGiftDrawResult struct {
|
||||||
state protoimpl.MessageState `protogen:"open.v1"`
|
state protoimpl.MessageState `protogen:"open.v1"`
|
||||||
DrawId string `protobuf:"bytes,1,opt,name=draw_id,json=drawId,proto3" json:"draw_id,omitempty"`
|
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"`
|
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"`
|
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"`
|
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
|
unknownFields protoimpl.UnknownFields
|
||||||
sizeCache protoimpl.SizeCache
|
sizeCache protoimpl.SizeCache
|
||||||
}
|
}
|
||||||
@ -9762,6 +9811,13 @@ func (x *LuckyGiftDrawResult) GetCreatedAtMs() int64 {
|
|||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (x *LuckyGiftDrawResult) GetPoolId() string {
|
||||||
|
if x != nil {
|
||||||
|
return x.PoolId
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
type ExecuteLuckyGiftDrawRequest struct {
|
type ExecuteLuckyGiftDrawRequest struct {
|
||||||
state protoimpl.MessageState `protogen:"open.v1"`
|
state protoimpl.MessageState `protogen:"open.v1"`
|
||||||
LuckyGift *LuckyGiftMeta `protobuf:"bytes,1,opt,name=lucky_gift,json=luckyGift,proto3" json:"lucky_gift,omitempty"`
|
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"`
|
state protoimpl.MessageState `protogen:"open.v1"`
|
||||||
Meta *RequestMeta `protobuf:"bytes,1,opt,name=meta,proto3" json:"meta,omitempty"`
|
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"`
|
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
|
unknownFields protoimpl.UnknownFields
|
||||||
sizeCache protoimpl.SizeCache
|
sizeCache protoimpl.SizeCache
|
||||||
}
|
}
|
||||||
@ -9902,6 +9959,13 @@ func (x *GetLuckyGiftConfigRequest) GetGiftId() string {
|
|||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (x *GetLuckyGiftConfigRequest) GetPoolId() string {
|
||||||
|
if x != nil {
|
||||||
|
return x.PoolId
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
type GetLuckyGiftConfigResponse struct {
|
type GetLuckyGiftConfigResponse struct {
|
||||||
state protoimpl.MessageState `protogen:"open.v1"`
|
state protoimpl.MessageState `protogen:"open.v1"`
|
||||||
Config *LuckyGiftConfig `protobuf:"bytes,1,opt,name=config,proto3" json:"config,omitempty"`
|
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"`
|
Status string `protobuf:"bytes,5,opt,name=status,proto3" json:"status,omitempty"`
|
||||||
Page int32 `protobuf:"varint,6,opt,name=page,proto3" json:"page,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"`
|
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
|
unknownFields protoimpl.UnknownFields
|
||||||
sizeCache protoimpl.SizeCache
|
sizeCache protoimpl.SizeCache
|
||||||
}
|
}
|
||||||
@ -10142,6 +10207,13 @@ func (x *ListLuckyGiftDrawsRequest) GetPageSize() int32 {
|
|||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (x *ListLuckyGiftDrawsRequest) GetPoolId() string {
|
||||||
|
if x != nil {
|
||||||
|
return x.PoolId
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
type ListLuckyGiftDrawsResponse struct {
|
type ListLuckyGiftDrawsResponse struct {
|
||||||
state protoimpl.MessageState `protogen:"open.v1"`
|
state protoimpl.MessageState `protogen:"open.v1"`
|
||||||
Draws []*LuckyGiftDrawResult `protobuf:"bytes,1,rep,name=draws,proto3" json:"draws,omitempty"`
|
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" +
|
"conditions\"\x8b\x01\n" +
|
||||||
"#UpsertAchievementDefinitionResponse\x12J\n" +
|
"#UpsertAchievementDefinitionResponse\x12J\n" +
|
||||||
"\vachievement\x18\x01 \x01(\v2(.hyapp.activity.v1.AchievementDefinitionR\vachievement\x12\x18\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" +
|
"\rLuckyGiftMeta\x122\n" +
|
||||||
"\x04meta\x18\x01 \x01(\v2\x1e.hyapp.activity.v1.RequestMetaR\x04meta\x12\x1d\n" +
|
"\x04meta\x18\x01 \x01(\v2\x1e.hyapp.activity.v1.RequestMetaR\x04meta\x12\x1d\n" +
|
||||||
"\n" +
|
"\n" +
|
||||||
@ -11027,14 +11099,17 @@ const file_proto_activity_v1_activity_proto_rawDesc = "" +
|
|||||||
"\n" +
|
"\n" +
|
||||||
"coin_spent\x18\b \x01(\x03R\tcoinSpent\x12\x1c\n" +
|
"coin_spent\x18\b \x01(\x03R\tcoinSpent\x12\x1c\n" +
|
||||||
"\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" +
|
"\rLuckyGiftTier\x12\x12\n" +
|
||||||
"\x04pool\x18\x01 \x01(\tR\x04pool\x12\x17\n" +
|
"\x04pool\x18\x01 \x01(\tR\x04pool\x12\x17\n" +
|
||||||
"\atier_id\x18\x02 \x01(\tR\x06tierId\x12!\n" +
|
"\atier_id\x18\x02 \x01(\tR\x06tierId\x12!\n" +
|
||||||
"\freward_coins\x18\x03 \x01(\x03R\vrewardCoins\x12\x16\n" +
|
"\freward_coins\x18\x03 \x01(\x03R\vrewardCoins\x12\x16\n" +
|
||||||
"\x06weight\x18\x04 \x01(\x03R\x06weight\x12&\n" +
|
"\x06weight\x18\x04 \x01(\x03R\x06weight\x12&\n" +
|
||||||
"\x0fhigh_water_only\x18\x05 \x01(\bR\rhighWaterOnly\x12\x18\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" +
|
"\x0fLuckyGiftConfig\x12\x19\n" +
|
||||||
"\bapp_code\x18\x01 \x01(\tR\aappCode\x12\x17\n" +
|
"\bapp_code\x18\x01 \x01(\tR\aappCode\x12\x17\n" +
|
||||||
"\agift_id\x18\x02 \x01(\tR\x06giftId\x12\x18\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" +
|
"\x05tiers\x18# \x03(\v2 .hyapp.activity.v1.LuckyGiftTierR\x05tiers\x12-\n" +
|
||||||
"\x13updated_by_admin_id\x18$ \x01(\x03R\x10updatedByAdminId\x12\"\n" +
|
"\x13updated_by_admin_id\x18$ \x01(\x03R\x10updatedByAdminId\x12\"\n" +
|
||||||
"\rcreated_at_ms\x18% \x01(\x03R\vcreatedAtMs\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" +
|
"\x15CheckLuckyGiftRequest\x122\n" +
|
||||||
"\x04meta\x18\x01 \x01(\v2\x1e.hyapp.activity.v1.RequestMetaR\x04meta\x12\x17\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" +
|
"\auser_id\x18\x02 \x01(\x03R\x06userId\x12\x17\n" +
|
||||||
"\aroom_id\x18\x03 \x01(\tR\x06roomId\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" +
|
"\x16CheckLuckyGiftResponse\x12\x18\n" +
|
||||||
"\aenabled\x18\x01 \x01(\bR\aenabled\x12\x16\n" +
|
"\aenabled\x18\x01 \x01(\bR\aenabled\x12\x16\n" +
|
||||||
"\x06reason\x18\x02 \x01(\tR\x06reason\x12\x17\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" +
|
"gift_price\x18\x04 \x01(\x03R\tgiftPrice\x12!\n" +
|
||||||
"\frule_version\x18\x05 \x01(\x03R\vruleVersion\x12$\n" +
|
"\frule_version\x18\x05 \x01(\x03R\vruleVersion\x12$\n" +
|
||||||
"\x0etarget_rtp_ppm\x18\x06 \x01(\x03R\ftargetRtpPpm\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" +
|
"\x13LuckyGiftDrawResult\x12\x17\n" +
|
||||||
"\adraw_id\x18\x01 \x01(\tR\x06drawId\x12\x1d\n" +
|
"\adraw_id\x18\x01 \x01(\tR\x06drawId\x12\x1d\n" +
|
||||||
"\n" +
|
"\n" +
|
||||||
@ -11111,15 +11190,17 @@ const file_proto_activity_v1_activity_proto_rawDesc = "" +
|
|||||||
"\x11gift_base_rtp_ppm\x18\x10 \x01(\x03R\x0egiftBaseRtpPpm\x12%\n" +
|
"\x11gift_base_rtp_ppm\x18\x10 \x01(\x03R\x0egiftBaseRtpPpm\x12%\n" +
|
||||||
"\x0estage_feedback\x18\x11 \x01(\bR\rstageFeedback\x12'\n" +
|
"\x0estage_feedback\x18\x11 \x01(\bR\rstageFeedback\x12'\n" +
|
||||||
"\x0fhigh_multiplier\x18\x12 \x01(\bR\x0ehighMultiplier\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" +
|
"\x1bExecuteLuckyGiftDrawRequest\x12?\n" +
|
||||||
"\n" +
|
"\n" +
|
||||||
"lucky_gift\x18\x01 \x01(\v2 .hyapp.activity.v1.LuckyGiftMetaR\tluckyGift\"^\n" +
|
"lucky_gift\x18\x01 \x01(\v2 .hyapp.activity.v1.LuckyGiftMetaR\tluckyGift\"^\n" +
|
||||||
"\x1cExecuteLuckyGiftDrawResponse\x12>\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" +
|
"\x19GetLuckyGiftConfigRequest\x122\n" +
|
||||||
"\x04meta\x18\x01 \x01(\v2\x1e.hyapp.activity.v1.RequestMetaR\x04meta\x12\x17\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" +
|
"\x1aGetLuckyGiftConfigResponse\x12:\n" +
|
||||||
"\x06config\x18\x01 \x01(\v2\".hyapp.activity.v1.LuckyGiftConfigR\x06config\"\xba\x01\n" +
|
"\x06config\x18\x01 \x01(\v2\".hyapp.activity.v1.LuckyGiftConfigR\x06config\"\xba\x01\n" +
|
||||||
"\x1cUpsertLuckyGiftConfigRequest\x122\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" +
|
"\x06config\x18\x02 \x01(\v2\".hyapp.activity.v1.LuckyGiftConfigR\x06config\x12*\n" +
|
||||||
"\x11operator_admin_id\x18\x03 \x01(\x03R\x0foperatorAdminId\"[\n" +
|
"\x11operator_admin_id\x18\x03 \x01(\x03R\x0foperatorAdminId\"[\n" +
|
||||||
"\x1dUpsertLuckyGiftConfigResponse\x12:\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" +
|
"\x19ListLuckyGiftDrawsRequest\x122\n" +
|
||||||
"\x04meta\x18\x01 \x01(\v2\x1e.hyapp.activity.v1.RequestMetaR\x04meta\x12\x17\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" +
|
"\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" +
|
"\aroom_id\x18\x04 \x01(\tR\x06roomId\x12\x16\n" +
|
||||||
"\x06status\x18\x05 \x01(\tR\x06status\x12\x12\n" +
|
"\x06status\x18\x05 \x01(\tR\x06status\x12\x12\n" +
|
||||||
"\x04page\x18\x06 \x01(\x05R\x04page\x12\x1b\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" +
|
"\x1aListLuckyGiftDrawsResponse\x12<\n" +
|
||||||
"\x05draws\x18\x01 \x03(\v2&.hyapp.activity.v1.LuckyGiftDrawResultR\x05draws\x12\x14\n" +
|
"\x05draws\x18\x01 \x03(\v2&.hyapp.activity.v1.LuckyGiftDrawResultR\x05draws\x12\x14\n" +
|
||||||
"\x05total\x18\x02 \x01(\x03R\x05total2\xe2\x01\n" +
|
"\x05total\x18\x02 \x01(\x03R\x05total2\xe2\x01\n" +
|
||||||
|
|||||||
@ -1006,6 +1006,7 @@ message LuckyGiftMeta {
|
|||||||
string gift_id = 7;
|
string gift_id = 7;
|
||||||
int64 coin_spent = 8;
|
int64 coin_spent = 8;
|
||||||
int64 paid_at_ms = 9;
|
int64 paid_at_ms = 9;
|
||||||
|
string pool_id = 10;
|
||||||
}
|
}
|
||||||
|
|
||||||
message LuckyGiftTier {
|
message LuckyGiftTier {
|
||||||
@ -1015,6 +1016,7 @@ message LuckyGiftTier {
|
|||||||
int64 weight = 4;
|
int64 weight = 4;
|
||||||
bool high_water_only = 5;
|
bool high_water_only = 5;
|
||||||
bool enabled = 6;
|
bool enabled = 6;
|
||||||
|
int64 multiplier_ppm = 7;
|
||||||
}
|
}
|
||||||
|
|
||||||
message LuckyGiftConfig {
|
message LuckyGiftConfig {
|
||||||
@ -1056,6 +1058,8 @@ message LuckyGiftConfig {
|
|||||||
int64 updated_by_admin_id = 36;
|
int64 updated_by_admin_id = 36;
|
||||||
int64 created_at_ms = 37;
|
int64 created_at_ms = 37;
|
||||||
int64 updated_at_ms = 38;
|
int64 updated_at_ms = 38;
|
||||||
|
string pool_id = 39;
|
||||||
|
repeated int64 multiplier_ppms = 40;
|
||||||
}
|
}
|
||||||
|
|
||||||
message CheckLuckyGiftRequest {
|
message CheckLuckyGiftRequest {
|
||||||
@ -1063,6 +1067,7 @@ message CheckLuckyGiftRequest {
|
|||||||
int64 user_id = 2;
|
int64 user_id = 2;
|
||||||
string room_id = 3;
|
string room_id = 3;
|
||||||
string gift_id = 4;
|
string gift_id = 4;
|
||||||
|
string pool_id = 5;
|
||||||
}
|
}
|
||||||
|
|
||||||
message CheckLuckyGiftResponse {
|
message CheckLuckyGiftResponse {
|
||||||
@ -1073,6 +1078,7 @@ message CheckLuckyGiftResponse {
|
|||||||
int64 rule_version = 5;
|
int64 rule_version = 5;
|
||||||
int64 target_rtp_ppm = 6;
|
int64 target_rtp_ppm = 6;
|
||||||
string experience_pool = 7;
|
string experience_pool = 7;
|
||||||
|
string pool_id = 8;
|
||||||
}
|
}
|
||||||
|
|
||||||
message LuckyGiftDrawResult {
|
message LuckyGiftDrawResult {
|
||||||
@ -1095,6 +1101,7 @@ message LuckyGiftDrawResult {
|
|||||||
bool stage_feedback = 17;
|
bool stage_feedback = 17;
|
||||||
bool high_multiplier = 18;
|
bool high_multiplier = 18;
|
||||||
int64 created_at_ms = 19;
|
int64 created_at_ms = 19;
|
||||||
|
string pool_id = 20;
|
||||||
}
|
}
|
||||||
|
|
||||||
message ExecuteLuckyGiftDrawRequest {
|
message ExecuteLuckyGiftDrawRequest {
|
||||||
@ -1108,6 +1115,7 @@ message ExecuteLuckyGiftDrawResponse {
|
|||||||
message GetLuckyGiftConfigRequest {
|
message GetLuckyGiftConfigRequest {
|
||||||
RequestMeta meta = 1;
|
RequestMeta meta = 1;
|
||||||
string gift_id = 2;
|
string gift_id = 2;
|
||||||
|
string pool_id = 3;
|
||||||
}
|
}
|
||||||
|
|
||||||
message GetLuckyGiftConfigResponse {
|
message GetLuckyGiftConfigResponse {
|
||||||
@ -1132,6 +1140,7 @@ message ListLuckyGiftDrawsRequest {
|
|||||||
string status = 5;
|
string status = 5;
|
||||||
int32 page = 6;
|
int32 page = 6;
|
||||||
int32 page_size = 7;
|
int32 page_size = 7;
|
||||||
|
string pool_id = 8;
|
||||||
}
|
}
|
||||||
|
|
||||||
message ListLuckyGiftDrawsResponse {
|
message ListLuckyGiftDrawsResponse {
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
|
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
|
||||||
// versions:
|
// versions:
|
||||||
// - protoc-gen-go-grpc v1.6.2
|
// - protoc-gen-go-grpc v1.6.2
|
||||||
// - protoc v5.28.3
|
// - protoc (unknown)
|
||||||
// source: proto/activity/v1/activity.proto
|
// source: proto/activity/v1/activity.proto
|
||||||
|
|
||||||
package activityv1
|
package activityv1
|
||||||
|
|||||||
@ -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"`
|
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"`
|
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"`
|
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
|
unknownFields protoimpl.UnknownFields
|
||||||
sizeCache protoimpl.SizeCache
|
sizeCache protoimpl.SizeCache
|
||||||
}
|
}
|
||||||
@ -1061,6 +1062,13 @@ func (x *RoomGiftSent) GetCommandId() string {
|
|||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (x *RoomGiftSent) GetPoolId() string {
|
||||||
|
if x != nil {
|
||||||
|
return x.PoolId
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
// RoomHeatChanged 表达热度变化结果。
|
// RoomHeatChanged 表达热度变化结果。
|
||||||
type RoomHeatChanged struct {
|
type RoomHeatChanged struct {
|
||||||
state protoimpl.MessageState `protogen:"open.v1"`
|
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" +
|
"\x0etarget_user_id\x18\x02 \x01(\x03R\ftargetUserId\"\\\n" +
|
||||||
"\x10RoomUserUnbanned\x12\"\n" +
|
"\x10RoomUserUnbanned\x12\"\n" +
|
||||||
"\ractor_user_id\x18\x01 \x01(\x03R\vactorUserId\x12$\n" +
|
"\ractor_user_id\x18\x01 \x01(\x03R\vactorUserId\x12$\n" +
|
||||||
"\x0etarget_user_id\x18\x02 \x01(\x03R\ftargetUserId\"\xaa\x02\n" +
|
"\x0etarget_user_id\x18\x02 \x01(\x03R\ftargetUserId\"\xc3\x02\n" +
|
||||||
"\fRoomGiftSent\x12$\n" +
|
"\fRoomGiftSent\x12$\n" +
|
||||||
"\x0esender_user_id\x18\x01 \x01(\x03R\fsenderUserId\x12$\n" +
|
"\x0esender_user_id\x18\x01 \x01(\x03R\fsenderUserId\x12$\n" +
|
||||||
"\x0etarget_user_id\x18\x02 \x01(\x03R\ftargetUserId\x12\x17\n" +
|
"\x0etarget_user_id\x18\x02 \x01(\x03R\ftargetUserId\x12\x17\n" +
|
||||||
@ -1809,7 +1817,8 @@ const file_proto_events_room_v1_events_proto_rawDesc = "" +
|
|||||||
"\x12billing_receipt_id\x18\x06 \x01(\tR\x10billingReceiptId\x12*\n" +
|
"\x12billing_receipt_id\x18\x06 \x01(\tR\x10billingReceiptId\x12*\n" +
|
||||||
"\x11visible_region_id\x18\a \x01(\x03R\x0fvisibleRegionId\x12\x1d\n" +
|
"\x11visible_region_id\x18\a \x01(\x03R\x0fvisibleRegionId\x12\x1d\n" +
|
||||||
"\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" +
|
"\x0fRoomHeatChanged\x12\x14\n" +
|
||||||
"\x05delta\x18\x01 \x01(\x03R\x05delta\x12!\n" +
|
"\x05delta\x18\x01 \x01(\x03R\x05delta\x12!\n" +
|
||||||
"\fcurrent_heat\x18\x02 \x01(\x03R\vcurrentHeat\"_\n" +
|
"\fcurrent_heat\x18\x02 \x01(\x03R\vcurrentHeat\"_\n" +
|
||||||
|
|||||||
@ -127,6 +127,7 @@ message RoomGiftSent {
|
|||||||
string billing_receipt_id = 6;
|
string billing_receipt_id = 6;
|
||||||
int64 visible_region_id = 7;
|
int64 visible_region_id = 7;
|
||||||
string command_id = 8;
|
string command_id = 8;
|
||||||
|
string pool_id = 9;
|
||||||
}
|
}
|
||||||
|
|
||||||
// RoomHeatChanged 表达热度变化结果。
|
// RoomHeatChanged 表达热度变化结果。
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
// Code generated by protoc-gen-go. DO NOT EDIT.
|
// Code generated by protoc-gen-go. DO NOT EDIT.
|
||||||
// versions:
|
// versions:
|
||||||
// protoc-gen-go v1.36.11
|
// protoc-gen-go v1.36.11
|
||||||
// protoc v5.28.3
|
// protoc (unknown)
|
||||||
// source: proto/game/v1/game.proto
|
// source: proto/game/v1/game.proto
|
||||||
|
|
||||||
package gamev1
|
package gamev1
|
||||||
@ -107,17 +107,23 @@ func (x *RequestMeta) GetAppCode() string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type GamePlatform struct {
|
type GamePlatform struct {
|
||||||
state protoimpl.MessageState `protogen:"open.v1"`
|
state protoimpl.MessageState `protogen:"open.v1"`
|
||||||
AppCode string `protobuf:"bytes,1,opt,name=app_code,json=appCode,proto3" json:"app_code,omitempty"`
|
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"`
|
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"`
|
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"`
|
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"`
|
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"`
|
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"`
|
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"`
|
UpdatedAtMs int64 `protobuf:"varint,8,opt,name=updated_at_ms,json=updatedAtMs,proto3" json:"updated_at_ms,omitempty"`
|
||||||
unknownFields protoimpl.UnknownFields
|
AdapterType string `protobuf:"bytes,9,opt,name=adapter_type,json=adapterType,proto3" json:"adapter_type,omitempty"`
|
||||||
sizeCache protoimpl.SizeCache
|
// 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() {
|
func (x *GamePlatform) Reset() {
|
||||||
@ -206,6 +212,41 @@ func (x *GamePlatform) GetUpdatedAtMs() int64 {
|
|||||||
return 0
|
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 {
|
type GameCatalogItem struct {
|
||||||
state protoimpl.MessageState `protogen:"open.v1"`
|
state protoimpl.MessageState `protogen:"open.v1"`
|
||||||
AppCode string `protobuf:"bytes,1,opt,name=app_code,json=appCode,proto3" json:"app_code,omitempty"`
|
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" +
|
"\ractor_user_id\x18\x04 \x01(\x03R\vactorUserId\x12\x1c\n" +
|
||||||
"\n" +
|
"\n" +
|
||||||
"sent_at_ms\x18\x05 \x01(\x03R\bsentAtMs\x12\x19\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" +
|
"\fGamePlatform\x12\x19\n" +
|
||||||
"\bapp_code\x18\x01 \x01(\tR\aappCode\x12#\n" +
|
"\bapp_code\x18\x01 \x01(\tR\aappCode\x12#\n" +
|
||||||
"\rplatform_code\x18\x02 \x01(\tR\fplatformCode\x12#\n" +
|
"\rplatform_code\x18\x02 \x01(\tR\fplatformCode\x12#\n" +
|
||||||
@ -1642,7 +1683,13 @@ const file_proto_game_v1_game_proto_rawDesc = "" +
|
|||||||
"\n" +
|
"\n" +
|
||||||
"sort_order\x18\x06 \x01(\x05R\tsortOrder\x12\"\n" +
|
"sort_order\x18\x06 \x01(\x05R\tsortOrder\x12\"\n" +
|
||||||
"\rcreated_at_ms\x18\a \x01(\x03R\vcreatedAtMs\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" +
|
"\x0fGameCatalogItem\x12\x19\n" +
|
||||||
"\bapp_code\x18\x01 \x01(\tR\aappCode\x12\x17\n" +
|
"\bapp_code\x18\x01 \x01(\tR\aappCode\x12\x17\n" +
|
||||||
"\agame_id\x18\x02 \x01(\tR\x06gameId\x12#\n" +
|
"\agame_id\x18\x02 \x01(\tR\x06gameId\x12#\n" +
|
||||||
|
|||||||
@ -23,6 +23,12 @@ message GamePlatform {
|
|||||||
int32 sort_order = 6;
|
int32 sort_order = 6;
|
||||||
int64 created_at_ms = 7;
|
int64 created_at_ms = 7;
|
||||||
int64 updated_at_ms = 8;
|
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 {
|
message GameCatalogItem {
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
|
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
|
||||||
// versions:
|
// versions:
|
||||||
// - protoc-gen-go-grpc v1.6.2
|
// - protoc-gen-go-grpc v1.6.2
|
||||||
// - protoc v5.28.3
|
// - protoc (unknown)
|
||||||
// source: proto/game/v1/game.proto
|
// source: proto/game/v1/game.proto
|
||||||
|
|
||||||
package gamev1
|
package gamev1
|
||||||
|
|||||||
@ -3657,6 +3657,7 @@ type SendGiftRequest struct {
|
|||||||
// target_type 当前只提交 user;后续扩展 all_mic、all_room、couple 时不再改 HTTP 契约。
|
// 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"`
|
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"`
|
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
|
unknownFields protoimpl.UnknownFields
|
||||||
sizeCache protoimpl.SizeCache
|
sizeCache protoimpl.SizeCache
|
||||||
}
|
}
|
||||||
@ -3733,6 +3734,13 @@ func (x *SendGiftRequest) GetTargetUserIds() []int64 {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (x *SendGiftRequest) GetPoolId() string {
|
||||||
|
if x != nil {
|
||||||
|
return x.PoolId
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
// SendGiftResponse 返回扣费成功并落到房间后的状态结果。
|
// SendGiftResponse 返回扣费成功并落到房间后的状态结果。
|
||||||
type SendGiftResponse struct {
|
type SendGiftResponse struct {
|
||||||
state protoimpl.MessageState `protogen:"open.v1"`
|
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" +
|
"\aroom_id\x18\x04 \x01(\tR\x06roomId\x12\x1d\n" +
|
||||||
"\n" +
|
"\n" +
|
||||||
"rtc_kicked\x18\x05 \x01(\bR\trtcKicked\x12$\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" +
|
"\x0fSendGiftRequest\x12.\n" +
|
||||||
"\x04meta\x18\x01 \x01(\v2\x1a.hyapp.room.v1.RequestMetaR\x04meta\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" +
|
"\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" +
|
"gift_count\x18\x04 \x01(\x05R\tgiftCount\x12\x1f\n" +
|
||||||
"\vtarget_type\x18\x05 \x01(\tR\n" +
|
"\vtarget_type\x18\x05 \x01(\tR\n" +
|
||||||
"targetType\x12&\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" +
|
"\x10SendGiftResponse\x124\n" +
|
||||||
"\x06result\x18\x01 \x01(\v2\x1c.hyapp.room.v1.CommandResultR\x06result\x12,\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" +
|
"\x12billing_receipt_id\x18\x02 \x01(\tR\x10billingReceiptId\x12\x1b\n" +
|
||||||
|
|||||||
@ -465,6 +465,7 @@ message SendGiftRequest {
|
|||||||
// target_type 当前只提交 user;后续扩展 all_mic、all_room、couple 时不再改 HTTP 契约。
|
// target_type 当前只提交 user;后续扩展 all_mic、all_room、couple 时不再改 HTTP 契约。
|
||||||
string target_type = 5;
|
string target_type = 5;
|
||||||
repeated int64 target_user_ids = 6;
|
repeated int64 target_user_ids = 6;
|
||||||
|
string pool_id = 7;
|
||||||
}
|
}
|
||||||
|
|
||||||
// SendGiftResponse 返回扣费成功并落到房间后的状态结果。
|
// SendGiftResponse 返回扣费成功并落到房间后的状态结果。
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
// Code generated by protoc-gen-go. DO NOT EDIT.
|
// Code generated by protoc-gen-go. DO NOT EDIT.
|
||||||
// versions:
|
// versions:
|
||||||
// protoc-gen-go v1.36.11
|
// protoc-gen-go v1.36.11
|
||||||
// protoc v5.28.3
|
// protoc (unknown)
|
||||||
// source: proto/user/v1/auth.proto
|
// source: proto/user/v1/auth.proto
|
||||||
|
|
||||||
package userv1
|
package userv1
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
|
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
|
||||||
// versions:
|
// versions:
|
||||||
// - protoc-gen-go-grpc v1.6.2
|
// - protoc-gen-go-grpc v1.6.2
|
||||||
// - protoc v5.28.3
|
// - protoc (unknown)
|
||||||
// source: proto/user/v1/auth.proto
|
// source: proto/user/v1/auth.proto
|
||||||
|
|
||||||
package userv1
|
package userv1
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
// Code generated by protoc-gen-go. DO NOT EDIT.
|
// Code generated by protoc-gen-go. DO NOT EDIT.
|
||||||
// versions:
|
// versions:
|
||||||
// protoc-gen-go v1.36.11
|
// protoc-gen-go v1.36.11
|
||||||
// protoc v5.28.3
|
// protoc (unknown)
|
||||||
// source: proto/user/v1/host.proto
|
// source: proto/user/v1/host.proto
|
||||||
|
|
||||||
package userv1
|
package userv1
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
|
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
|
||||||
// versions:
|
// versions:
|
||||||
// - protoc-gen-go-grpc v1.6.2
|
// - protoc-gen-go-grpc v1.6.2
|
||||||
// - protoc v5.28.3
|
// - protoc (unknown)
|
||||||
// source: proto/user/v1/host.proto
|
// source: proto/user/v1/host.proto
|
||||||
|
|
||||||
package userv1
|
package userv1
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
// Code generated by protoc-gen-go. DO NOT EDIT.
|
// Code generated by protoc-gen-go. DO NOT EDIT.
|
||||||
// versions:
|
// versions:
|
||||||
// protoc-gen-go v1.36.11
|
// protoc-gen-go v1.36.11
|
||||||
// protoc v5.28.3
|
// protoc (unknown)
|
||||||
// source: proto/user/v1/user.proto
|
// source: proto/user/v1/user.proto
|
||||||
|
|
||||||
package userv1
|
package userv1
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
|
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
|
||||||
// versions:
|
// versions:
|
||||||
// - protoc-gen-go-grpc v1.6.2
|
// - protoc-gen-go-grpc v1.6.2
|
||||||
// - protoc v5.28.3
|
// - protoc (unknown)
|
||||||
// source: proto/user/v1/user.proto
|
// source: proto/user/v1/user.proto
|
||||||
|
|
||||||
package userv1
|
package userv1
|
||||||
|
|||||||
@ -32,7 +32,8 @@ room-service 实例之间通过 Redis owner route 和 node registry 直连转发
|
|||||||
| 层级 | 地址 | 说明 |
|
| 层级 | 地址 | 说明 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| 公网 API | `https://api.global-interaction.com` | HTTPS 443 由公网 CLB 转发到两台 `gateway-service:13000` |
|
| 公网 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 服务监听器 |
|
| 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 服务监听器 |
|
| wallet 内网入口 | `10.2.1.15:13004` | `lb-4f5xi6p0` 的 Go 服务监听器 |
|
||||||
|
|
||||||
|
|||||||
611
docs/flutter对接/语音房宝箱Flutter对接.md
Normal file
611
docs/flutter对接/语音房宝箱Flutter对接.md
Normal file
@ -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 <access_token>`。 |
|
||||||
|
| `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 <access_token>
|
||||||
|
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 <access_token>
|
||||||
|
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<string,string> | 业务字段;值全部按字符串传输,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<Map<String, dynamic>> 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>().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` 重新同步。
|
||||||
@ -10,8 +10,8 @@
|
|||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `LuckyGiftService.CheckLuckyGift` | done | 房间送礼前检查礼物是否启用,并返回当前体验池 |
|
| `LuckyGiftService.CheckLuckyGift` | done | 房间送礼前检查礼物是否启用,并返回当前体验池 |
|
||||||
| `LuckyGiftService.ExecuteLuckyGiftDraw` | done | 扣费成功后执行抽奖决策,写抽奖事实和 outbox |
|
| `LuckyGiftService.ExecuteLuckyGiftDraw` | done | 扣费成功后执行抽奖决策,写抽奖事实和 outbox |
|
||||||
| `AdminLuckyGiftService.GetLuckyGiftConfig` | done | 后台读取全局幸运礼物规则;未配置时返回默认草稿 |
|
| `AdminLuckyGiftService.GetLuckyGiftConfig` | done | 后台按 `pool_id` 读取幸运礼物奖池规则;未配置时返回默认草稿 |
|
||||||
| `AdminLuckyGiftService.UpsertLuckyGiftConfig` | done | 后台发布全局幸运礼物规则版本 |
|
| `AdminLuckyGiftService.UpsertLuckyGiftConfig` | done | 后台按 `pool_id` 发布独立幸运礼物奖池规则版本 |
|
||||||
| `AdminLuckyGiftService.ListLuckyGiftDraws` | done | 后台审计抽奖记录 |
|
| `AdminLuckyGiftService.ListLuckyGiftDraws` | done | 后台审计抽奖记录 |
|
||||||
| 三层基础奖池 | done | platform / room / gift |
|
| 三层基础奖池 | done | platform / room / gift |
|
||||||
| RTP 双窗口 | done | global + gift |
|
| RTP 双窗口 | done | global + gift |
|
||||||
@ -25,23 +25,23 @@
|
|||||||
|
|
||||||
```text
|
```text
|
||||||
room-service SendGift
|
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 扣费成功
|
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
|
4. activity-service 本地事务写 draw fact + outbox
|
||||||
5. 事务后由钱包/房间表现链路处理 reward_status=pending 的结果
|
5. 事务后由钱包/房间表现链路处理 reward_status=pending 的结果
|
||||||
```
|
```
|
||||||
|
|
||||||
`command_id` 是抽奖幂等键。重复 `ExecuteLuckyGiftDraw` 会返回同一条 `lucky_draw_records`,不会重复消耗 RTP、奖池或预算。
|
`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
|
## Transaction Boundary
|
||||||
|
|
||||||
`ExecuteLuckyGiftDraw` 使用一个 MySQL 本地事务,事务内必须完成:
|
`ExecuteLuckyGiftDraw` 使用一个 MySQL 本地事务,事务内必须完成:
|
||||||
|
|
||||||
1. 按 `command_id` 锁定幂等记录。
|
1. 按 `command_id` 锁定幂等记录。
|
||||||
2. 锁定 `lucky_gift_rules` 全局规则。
|
2. 锁定 `lucky_gift_rules` 中 `pool_id` 对应规则。
|
||||||
3. 锁定或创建用户体验状态 `lucky_user_states`。
|
3. 锁定或创建用户体验状态 `lucky_user_states`。
|
||||||
4. 锁定或创建全站 RTP 窗口、幸运礼物整体子窗口。
|
4. 锁定或创建全站 RTP 窗口、幸运礼物整体子窗口。
|
||||||
5. 锁定或创建平台、房间、幸运礼物整体三层基础奖池。
|
5. 锁定或创建平台、房间、幸运礼物整体三层基础奖池。
|
||||||
@ -82,16 +82,17 @@ effective_reward_coins = base_reward_coins
|
|||||||
|
|
||||||
| Method | Path | Permission | Meaning |
|
| Method | Path | Permission | Meaning |
|
||||||
| --- | --- | --- | --- |
|
| --- | --- | --- | --- |
|
||||||
| `GET` | `/api/v1/admin/activity/lucky-gifts/config` | `lucky-gift:view` | 读取全局幸运礼物规则;未配置返回默认草稿 |
|
| `GET` | `/api/v1/admin/activity/lucky-gifts/config?pool_id=pool_95` | `lucky-gift:view` | 读取指定奖池规则;未配置返回默认草稿 |
|
||||||
| `PUT` | `/api/v1/admin/activity/lucky-gifts/config` | `lucky-gift:update` | 发布新的全局幸运礼物规则版本 |
|
| `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` | 查询抽奖审计记录 |
|
| `GET` | `/api/v1/admin/activity/lucky-gifts/draws` | `lucky-gift:view` | 查询抽奖审计记录 |
|
||||||
|
|
||||||
| Group | Fields | Meaning |
|
| Group | Fields | Meaning |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| RTP 成本 | `target_rtp_ppm`, `pool_rate_ppm`, `global_window_draws`, `gift_window_draws` | 控制基础 RTP 和收敛窗口 |
|
| 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` | 控制平台、房间、礼物各自承担的基础返奖能力 |
|
| 三层池 | `platform_pool_weight_ppm`, `room_pool_weight_ppm`, `gift_pool_weight_ppm`, `initial_*_pool`, `*_reserve` | 控制平台、房间、礼物各自承担的基础返奖能力 |
|
||||||
| 体验池 | `novice_draw_limit`, `intermediate_draw_limit`, `tiers` | 控制新手/中级/高级奖档结构 |
|
| 倍率配置 | `multiplier_ppms`, generated `tiers` | 运营只配置用户可能命中的倍率;服务按 RTP 窗口、水位和风控自动生成实时概率 |
|
||||||
| 高倍 | `high_multiplier`, `high_water_pool_multiple`, `large_tier_enabled`, tier `high_water_only` | 控制 100x/500x 等高倍是否可进入候选 |
|
| 体验池 | `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` | 控制单用户、设备、房间、主播关联风险敞口 |
|
| 风控 | `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` | 控制房间爆点和活动补贴 |
|
| 娱乐补贴 | `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 |
|
| Goal | Primary Change |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| 降成本 | 降 `target_rtp_ppm`,降补贴预算,提高高水位倍数 |
|
| 降成本 | 降 `target_rtp_ppm`,降补贴预算,提高高水位倍数 |
|
||||||
| 增刺激 | 增房间气氛和活动预算,提高中高倍权重 |
|
| 增刺激 | 增加高倍倍率,增房间气氛和活动预算 |
|
||||||
| 新手更友好 | 提高新手小奖权重,延长 `novice_draw_limit` |
|
| 新手更友好 | 增加 0.2x/0.5x/1x 等小倍率,延长 `novice_draw_limit` |
|
||||||
| 防套利 | 降低用户/设备/房间/主播上限,关闭高倍 |
|
| 防套利 | 降低用户/设备/房间/主播上限,关闭高倍 |
|
||||||
| RTP 更稳 | 缩小 RTP 窗口 |
|
| RTP 更稳 | 缩小 RTP 窗口 |
|
||||||
| 体验更自然 | 放大 RTP 窗口,但接受短周期波动 |
|
| 体验更自然 | 放大 RTP 窗口,但接受短周期波动 |
|
||||||
@ -110,13 +111,13 @@ effective_reward_coins = base_reward_coins
|
|||||||
|
|
||||||
| Table | Owner Data |
|
| Table | Owner Data |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `lucky_gift_rules` | 全局幸运礼物规则和后台可调参数;规则作用域固定为 `__global__` |
|
| `lucky_gift_rules` | 按 `pool_id` 保存幸运礼物奖池规则和后台可调参数 |
|
||||||
| `lucky_rtp_windows` | 全站/幸运礼物整体 RTP 窗口 |
|
| `lucky_rtp_windows` | 按 `pool_id` 保存 RTP 控制窗口 |
|
||||||
| `lucky_pools` | platform / room / lucky-gift-global 三层基础奖池 |
|
| `lucky_pools` | 按 `pool_id` 保存 platform / room / gift 三层基础奖池 |
|
||||||
| `lucky_room_atmosphere_pools` | 房间气氛预算 |
|
| `lucky_room_atmosphere_pools` | 按 `pool_id + room_id` 保存房间气氛预算 |
|
||||||
| `lucky_activity_budgets` | 活动补贴预算;`budget_day=__total__` 控总额,UTC 日期行控日释放 |
|
| `lucky_activity_budgets` | 按 `pool_id` 保存活动补贴预算;`budget_day=__total__` 控总额,UTC 日期行控日释放 |
|
||||||
| `lucky_user_states` | 用户在幸运礼物整体上的累计付费抽数 |
|
| `lucky_user_states` | 用户在单个 `pool_id` 内的累计付费抽数 |
|
||||||
| `lucky_risk_counters` | 风控窗口计数 |
|
| `lucky_risk_counters` | 按 `pool_id` 保存风控窗口计数 |
|
||||||
| `lucky_draw_records` | 每次抽奖事实和审计快照 |
|
| `lucky_draw_records` | 每次抽奖事实和审计快照 |
|
||||||
|
|
||||||
## Verification
|
## Verification
|
||||||
|
|||||||
@ -3,14 +3,18 @@ package gamemanagement
|
|||||||
import gamev1 "hyapp.local/api/proto/game/v1"
|
import gamev1 "hyapp.local/api/proto/game/v1"
|
||||||
|
|
||||||
type platformDTO struct {
|
type platformDTO struct {
|
||||||
AppCode string `json:"appCode"`
|
AppCode string `json:"appCode"`
|
||||||
PlatformCode string `json:"platformCode"`
|
PlatformCode string `json:"platformCode"`
|
||||||
PlatformName string `json:"platformName"`
|
PlatformName string `json:"platformName"`
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
APIBaseURL string `json:"apiBaseUrl"`
|
APIBaseURL string `json:"apiBaseUrl"`
|
||||||
SortOrder int32 `json:"sortOrder"`
|
AdapterType string `json:"adapterType"`
|
||||||
CreatedAtMS int64 `json:"createdAtMs"`
|
CallbackSecretSet bool `json:"callbackSecretSet"`
|
||||||
UpdatedAtMS int64 `json:"updatedAtMs"`
|
CallbackIPWhitelist []string `json:"callbackIpWhitelist"`
|
||||||
|
AdapterConfigJSON string `json:"adapterConfigJson"`
|
||||||
|
SortOrder int32 `json:"sortOrder"`
|
||||||
|
CreatedAtMS int64 `json:"createdAtMs"`
|
||||||
|
UpdatedAtMS int64 `json:"updatedAtMs"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type catalogDTO struct {
|
type catalogDTO struct {
|
||||||
@ -37,14 +41,18 @@ func platformFromProto(item *gamev1.GamePlatform) platformDTO {
|
|||||||
return platformDTO{}
|
return platformDTO{}
|
||||||
}
|
}
|
||||||
return platformDTO{
|
return platformDTO{
|
||||||
AppCode: item.GetAppCode(),
|
AppCode: item.GetAppCode(),
|
||||||
PlatformCode: item.GetPlatformCode(),
|
PlatformCode: item.GetPlatformCode(),
|
||||||
PlatformName: item.GetPlatformName(),
|
PlatformName: item.GetPlatformName(),
|
||||||
Status: item.GetStatus(),
|
Status: item.GetStatus(),
|
||||||
APIBaseURL: item.GetApiBaseUrl(),
|
APIBaseURL: item.GetApiBaseUrl(),
|
||||||
SortOrder: item.GetSortOrder(),
|
AdapterType: item.GetAdapterType(),
|
||||||
CreatedAtMS: item.GetCreatedAtMs(),
|
CallbackSecretSet: item.GetCallbackSecretSet(),
|
||||||
UpdatedAtMS: item.GetUpdatedAtMs(),
|
CallbackIPWhitelist: item.GetCallbackIpWhitelist(),
|
||||||
|
AdapterConfigJSON: item.GetAdapterConfigJson(),
|
||||||
|
SortOrder: item.GetSortOrder(),
|
||||||
|
CreatedAtMS: item.GetCreatedAtMs(),
|
||||||
|
UpdatedAtMS: item.GetUpdatedAtMs(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -12,11 +12,15 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type platformRequest struct {
|
type platformRequest struct {
|
||||||
PlatformCode string `json:"platformCode"`
|
PlatformCode string `json:"platformCode"`
|
||||||
PlatformName string `json:"platformName"`
|
PlatformName string `json:"platformName"`
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
APIBaseURL string `json:"apiBaseUrl"`
|
APIBaseURL string `json:"apiBaseUrl"`
|
||||||
SortOrder int32 `json:"sortOrder"`
|
AdapterType string `json:"adapterType"`
|
||||||
|
CallbackSecret string `json:"callbackSecret"`
|
||||||
|
CallbackIPWhitelist []string `json:"callbackIpWhitelist"`
|
||||||
|
AdapterConfigJSON string `json:"adapterConfigJson"`
|
||||||
|
SortOrder int32 `json:"sortOrder"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type catalogRequest struct {
|
type catalogRequest struct {
|
||||||
@ -44,11 +48,15 @@ func (r platformRequest) toProto(platformCode string) *gamev1.GamePlatform {
|
|||||||
platformCode = r.PlatformCode
|
platformCode = r.PlatformCode
|
||||||
}
|
}
|
||||||
return &gamev1.GamePlatform{
|
return &gamev1.GamePlatform{
|
||||||
PlatformCode: strings.TrimSpace(platformCode),
|
PlatformCode: strings.TrimSpace(platformCode),
|
||||||
PlatformName: strings.TrimSpace(r.PlatformName),
|
PlatformName: strings.TrimSpace(r.PlatformName),
|
||||||
Status: strings.TrimSpace(r.Status),
|
Status: strings.TrimSpace(r.Status),
|
||||||
ApiBaseUrl: strings.TrimSpace(r.APIBaseURL),
|
ApiBaseUrl: strings.TrimSpace(r.APIBaseURL),
|
||||||
SortOrder: r.SortOrder,
|
AdapterType: strings.TrimSpace(r.AdapterType),
|
||||||
|
CallbackSecret: strings.TrimSpace(r.CallbackSecret),
|
||||||
|
CallbackIpWhitelist: compactTags(r.CallbackIPWhitelist),
|
||||||
|
AdapterConfigJson: strings.TrimSpace(r.AdapterConfigJSON),
|
||||||
|
SortOrder: r.SortOrder,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -24,6 +24,7 @@ func New(activity activityclient.Client, audit shared.OperationLogger) *Handler
|
|||||||
}
|
}
|
||||||
|
|
||||||
type configRequest struct {
|
type configRequest struct {
|
||||||
|
PoolID string `json:"pool_id"`
|
||||||
Enabled bool `json:"enabled"`
|
Enabled bool `json:"enabled"`
|
||||||
GiftPrice int64 `json:"gift_price"`
|
GiftPrice int64 `json:"gift_price"`
|
||||||
TargetRTPPPM int64 `json:"target_rtp_ppm"`
|
TargetRTPPPM int64 `json:"target_rtp_ppm"`
|
||||||
@ -55,6 +56,7 @@ type configRequest struct {
|
|||||||
ActivityBudget int64 `json:"activity_budget"`
|
ActivityBudget int64 `json:"activity_budget"`
|
||||||
ActivityDailyLimit int64 `json:"activity_daily_limit"`
|
ActivityDailyLimit int64 `json:"activity_daily_limit"`
|
||||||
LargeTierEnabled bool `json:"large_tier_enabled"`
|
LargeTierEnabled bool `json:"large_tier_enabled"`
|
||||||
|
MultiplierPPMs []int64 `json:"multiplier_ppms"`
|
||||||
Tiers []tierDTO `json:"tiers"`
|
Tiers []tierDTO `json:"tiers"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -72,6 +74,7 @@ type tierDTO struct {
|
|||||||
Pool string `json:"pool"`
|
Pool string `json:"pool"`
|
||||||
TierID string `json:"tier_id"`
|
TierID string `json:"tier_id"`
|
||||||
RewardCoins int64 `json:"reward_coins"`
|
RewardCoins int64 `json:"reward_coins"`
|
||||||
|
MultiplierPPM int64 `json:"multiplier_ppm"`
|
||||||
Weight int64 `json:"weight"`
|
Weight int64 `json:"weight"`
|
||||||
HighWaterOnly bool `json:"high_water_only"`
|
HighWaterOnly bool `json:"high_water_only"`
|
||||||
Enabled bool `json:"enabled"`
|
Enabled bool `json:"enabled"`
|
||||||
@ -80,6 +83,7 @@ type tierDTO struct {
|
|||||||
type drawDTO struct {
|
type drawDTO struct {
|
||||||
DrawID string `json:"draw_id"`
|
DrawID string `json:"draw_id"`
|
||||||
CommandID string `json:"command_id"`
|
CommandID string `json:"command_id"`
|
||||||
|
PoolID string `json:"pool_id"`
|
||||||
GiftID string `json:"gift_id"`
|
GiftID string `json:"gift_id"`
|
||||||
RuleVersion int64 `json:"rule_version"`
|
RuleVersion int64 `json:"rule_version"`
|
||||||
ExperiencePool string `json:"experience_pool"`
|
ExperiencePool string `json:"experience_pool"`
|
||||||
@ -101,7 +105,8 @@ type drawDTO struct {
|
|||||||
|
|
||||||
func (h *Handler) GetLuckyGiftConfig(c *gin.Context) {
|
func (h *Handler) GetLuckyGiftConfig(c *gin.Context) {
|
||||||
resp, err := h.activity.GetLuckyGiftConfig(c.Request.Context(), &activityv1.GetLuckyGiftConfigRequest{
|
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 {
|
if err != nil {
|
||||||
response.ServerError(c, "获取幸运礼物配置失败")
|
response.ServerError(c, "获取幸运礼物配置失败")
|
||||||
@ -116,6 +121,9 @@ func (h *Handler) UpsertLuckyGiftConfig(c *gin.Context) {
|
|||||||
response.BadRequest(c, "幸运礼物配置参数不正确")
|
response.BadRequest(c, "幸运礼物配置参数不正确")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if req.PoolID == "" {
|
||||||
|
req.PoolID = strings.TrimSpace(c.Query("pool_id"))
|
||||||
|
}
|
||||||
resp, err := h.activity.UpsertLuckyGiftConfig(c.Request.Context(), &activityv1.UpsertLuckyGiftConfigRequest{
|
resp, err := h.activity.UpsertLuckyGiftConfig(c.Request.Context(), &activityv1.UpsertLuckyGiftConfigRequest{
|
||||||
Meta: h.meta(c),
|
Meta: h.meta(c),
|
||||||
Config: configToProto(req),
|
Config: configToProto(req),
|
||||||
@ -126,7 +134,7 @@ func (h *Handler) UpsertLuckyGiftConfig(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
item := configFromProto(resp.GetConfig())
|
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)
|
response.OK(c, item)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -134,6 +142,7 @@ func (h *Handler) ListLuckyGiftDraws(c *gin.Context) {
|
|||||||
options := shared.ListOptions(c)
|
options := shared.ListOptions(c)
|
||||||
resp, err := h.activity.ListLuckyGiftDraws(c.Request.Context(), &activityv1.ListLuckyGiftDrawsRequest{
|
resp, err := h.activity.ListLuckyGiftDraws(c.Request.Context(), &activityv1.ListLuckyGiftDrawsRequest{
|
||||||
Meta: h.meta(c),
|
Meta: h.meta(c),
|
||||||
|
PoolId: strings.TrimSpace(c.Query("pool_id")),
|
||||||
GiftId: strings.TrimSpace(c.Query("gift_id")),
|
GiftId: strings.TrimSpace(c.Query("gift_id")),
|
||||||
UserId: parseOptionalInt64(c.Query("user_id")),
|
UserId: parseOptionalInt64(c.Query("user_id")),
|
||||||
RoomId: strings.TrimSpace(c.Query("room_id")),
|
RoomId: strings.TrimSpace(c.Query("room_id")),
|
||||||
@ -168,12 +177,14 @@ func configToProto(req configRequest) *activityv1.LuckyGiftConfig {
|
|||||||
Pool: tier.Pool,
|
Pool: tier.Pool,
|
||||||
TierId: tier.TierID,
|
TierId: tier.TierID,
|
||||||
RewardCoins: tier.RewardCoins,
|
RewardCoins: tier.RewardCoins,
|
||||||
|
MultiplierPpm: tier.MultiplierPPM,
|
||||||
Weight: tier.Weight,
|
Weight: tier.Weight,
|
||||||
HighWaterOnly: tier.HighWaterOnly,
|
HighWaterOnly: tier.HighWaterOnly,
|
||||||
Enabled: tier.Enabled,
|
Enabled: tier.Enabled,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
return &activityv1.LuckyGiftConfig{
|
return &activityv1.LuckyGiftConfig{
|
||||||
|
PoolId: strings.TrimSpace(req.PoolID),
|
||||||
Enabled: req.Enabled,
|
Enabled: req.Enabled,
|
||||||
GiftPrice: req.GiftPrice,
|
GiftPrice: req.GiftPrice,
|
||||||
TargetRtpPpm: req.TargetRTPPPM,
|
TargetRtpPpm: req.TargetRTPPPM,
|
||||||
@ -205,6 +216,7 @@ func configToProto(req configRequest) *activityv1.LuckyGiftConfig {
|
|||||||
ActivityBudget: req.ActivityBudget,
|
ActivityBudget: req.ActivityBudget,
|
||||||
ActivityDailyLimit: req.ActivityDailyLimit,
|
ActivityDailyLimit: req.ActivityDailyLimit,
|
||||||
LargeTierEnabled: req.LargeTierEnabled,
|
LargeTierEnabled: req.LargeTierEnabled,
|
||||||
|
MultiplierPpms: append([]int64(nil), req.MultiplierPPMs...),
|
||||||
Tiers: tiers,
|
Tiers: tiers,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -219,11 +231,16 @@ func configFromProto(config *activityv1.LuckyGiftConfig) configDTO {
|
|||||||
Pool: tier.GetPool(),
|
Pool: tier.GetPool(),
|
||||||
TierID: tier.GetTierId(),
|
TierID: tier.GetTierId(),
|
||||||
RewardCoins: tier.GetRewardCoins(),
|
RewardCoins: tier.GetRewardCoins(),
|
||||||
|
MultiplierPPM: tier.GetMultiplierPpm(),
|
||||||
Weight: tier.GetWeight(),
|
Weight: tier.GetWeight(),
|
||||||
HighWaterOnly: tier.GetHighWaterOnly(),
|
HighWaterOnly: tier.GetHighWaterOnly(),
|
||||||
Enabled: tier.GetEnabled(),
|
Enabled: tier.GetEnabled(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
poolID := strings.TrimSpace(config.GetPoolId())
|
||||||
|
if poolID == "" {
|
||||||
|
poolID = strings.TrimSpace(config.GetGiftId())
|
||||||
|
}
|
||||||
return configDTO{
|
return configDTO{
|
||||||
AppCode: config.GetAppCode(),
|
AppCode: config.GetAppCode(),
|
||||||
GiftID: config.GetGiftId(),
|
GiftID: config.GetGiftId(),
|
||||||
@ -232,6 +249,7 @@ func configFromProto(config *activityv1.LuckyGiftConfig) configDTO {
|
|||||||
CreatedAtMS: config.GetCreatedAtMs(),
|
CreatedAtMS: config.GetCreatedAtMs(),
|
||||||
UpdatedAtMS: config.GetUpdatedAtMs(),
|
UpdatedAtMS: config.GetUpdatedAtMs(),
|
||||||
configRequest: configRequest{
|
configRequest: configRequest{
|
||||||
|
PoolID: poolID,
|
||||||
Enabled: config.GetEnabled(),
|
Enabled: config.GetEnabled(),
|
||||||
GiftPrice: config.GetGiftPrice(),
|
GiftPrice: config.GetGiftPrice(),
|
||||||
TargetRTPPPM: config.GetTargetRtpPpm(),
|
TargetRTPPPM: config.GetTargetRtpPpm(),
|
||||||
@ -263,6 +281,7 @@ func configFromProto(config *activityv1.LuckyGiftConfig) configDTO {
|
|||||||
ActivityBudget: config.GetActivityBudget(),
|
ActivityBudget: config.GetActivityBudget(),
|
||||||
ActivityDailyLimit: config.GetActivityDailyLimit(),
|
ActivityDailyLimit: config.GetActivityDailyLimit(),
|
||||||
LargeTierEnabled: config.GetLargeTierEnabled(),
|
LargeTierEnabled: config.GetLargeTierEnabled(),
|
||||||
|
MultiplierPPMs: append([]int64(nil), config.GetMultiplierPpms()...),
|
||||||
Tiers: tiers,
|
Tiers: tiers,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@ -275,6 +294,7 @@ func drawFromProto(draw *activityv1.LuckyGiftDrawResult) drawDTO {
|
|||||||
return drawDTO{
|
return drawDTO{
|
||||||
DrawID: draw.GetDrawId(),
|
DrawID: draw.GetDrawId(),
|
||||||
CommandID: draw.GetCommandId(),
|
CommandID: draw.GetCommandId(),
|
||||||
|
PoolID: draw.GetPoolId(),
|
||||||
GiftID: draw.GetGiftId(),
|
GiftID: draw.GetGiftId(),
|
||||||
RuleVersion: draw.GetRuleVersion(),
|
RuleVersion: draw.GetRuleVersion(),
|
||||||
ExperiencePool: draw.GetExperiencePool(),
|
ExperiencePool: draw.GetExperiencePool(),
|
||||||
|
|||||||
@ -128,7 +128,7 @@ var deprecatedPermissionCodes = []string{
|
|||||||
"service:view", "service:create", "service:update", "service:operate",
|
"service:view", "service:create", "service:update", "service:operate",
|
||||||
"notification:view", "notification:read", "notification:read-all", "notification:delete",
|
"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 {
|
func (s *Store) seedPermissions() error {
|
||||||
return s.syncDefaultPermissions(s.db)
|
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: "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: "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: "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: &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-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},
|
{ParentID: &geoID, Title: "区域管理", Code: "host-org-regions", Path: "/host/regions", Icon: "map", PermissionCode: "region:view", Sort: 71, Visible: true},
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci;
|
SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
|
||||||
-- 这两个入口不再作为后台导航展示;权限和接口保留,避免影响已有内部能力。
|
-- 任务入口不再作为后台导航展示;权限和接口保留,避免影响已有内部能力。
|
||||||
DELETE FROM admin_menus
|
DELETE FROM admin_menus
|
||||||
WHERE code IN ('jobs', 'room-treasure')
|
WHERE code = 'jobs'
|
||||||
OR path IN ('/jobs', '/activities/room-treasure');
|
OR path = '/jobs';
|
||||||
|
|||||||
@ -46,7 +46,7 @@ CREATE TABLE IF NOT EXISTS activity_outbox (
|
|||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS lucky_gift_rules (
|
CREATE TABLE IF NOT EXISTS lucky_gift_rules (
|
||||||
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码',
|
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 '是否启用',
|
enabled TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否启用',
|
||||||
rule_version BIGINT NOT NULL COMMENT '规则版本',
|
rule_version BIGINT NOT NULL COMMENT '规则版本',
|
||||||
gift_price 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_budget BIGINT NOT NULL COMMENT '活动补贴预算',
|
||||||
activity_daily_limit BIGINT NOT NULL COMMENT '活动补贴日上限',
|
activity_daily_limit BIGINT NOT NULL COMMENT '活动补贴日上限',
|
||||||
large_tier_enabled TINYINT(1) NOT NULL DEFAULT 1 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 '最后更新管理员',
|
updated_by_admin_id BIGINT NOT NULL DEFAULT 0 COMMENT '最后更新管理员',
|
||||||
created_at_ms BIGINT NOT NULL COMMENT '创建时间,UTC epoch ms',
|
created_at_ms BIGINT NOT NULL COMMENT '创建时间,UTC epoch ms',
|
||||||
updated_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 (
|
CREATE TABLE IF NOT EXISTS lucky_activity_budgets (
|
||||||
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码',
|
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_day VARCHAR(32) NOT NULL COMMENT 'UTC 预算日;__total__ 表示活动总预算',
|
||||||
budget_coins BIGINT NOT NULL DEFAULT 0 COMMENT '总补贴预算',
|
budget_coins BIGINT NOT NULL DEFAULT 0 COMMENT '总补贴预算',
|
||||||
spent_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 (
|
CREATE TABLE IF NOT EXISTS lucky_user_states (
|
||||||
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码',
|
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码',
|
||||||
user_id BIGINT NOT NULL COMMENT '用户 ID',
|
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 '该用户幸运礼物累计付费抽数',
|
paid_draws BIGINT NOT NULL DEFAULT 0 COMMENT '该用户幸运礼物累计付费抽数',
|
||||||
created_at_ms BIGINT NOT NULL COMMENT '创建时间,UTC epoch ms',
|
created_at_ms BIGINT NOT NULL COMMENT '创建时间,UTC epoch ms',
|
||||||
updated_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',
|
device_id VARCHAR(128) NOT NULL COMMENT '设备 ID',
|
||||||
room_id VARCHAR(96) NOT NULL COMMENT '房间 ID',
|
room_id VARCHAR(96) NOT NULL COMMENT '房间 ID',
|
||||||
anchor_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',
|
gift_id VARCHAR(96) NOT NULL COMMENT '礼物 ID',
|
||||||
coin_spent BIGINT NOT NULL COMMENT '消耗金币',
|
coin_spent BIGINT NOT NULL COMMENT '消耗金币',
|
||||||
rule_version 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',
|
updated_at_ms BIGINT NOT NULL COMMENT '更新时间,UTC epoch ms',
|
||||||
PRIMARY KEY (app_code, draw_id),
|
PRIMARY KEY (app_code, draw_id),
|
||||||
UNIQUE KEY uk_lucky_draw_command (app_code, command_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_user (app_code, user_id, created_at_ms),
|
||||||
KEY idx_lucky_draw_gift (app_code, gift_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),
|
KEY idx_lucky_draw_room (app_code, room_id, created_at_ms),
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
package luckygift
|
package luckygift
|
||||||
|
|
||||||
const (
|
const (
|
||||||
GlobalConfigGiftID = "__global__"
|
DefaultPoolID = "default"
|
||||||
|
|
||||||
StatusDraft = "draft"
|
StatusDraft = "draft"
|
||||||
StatusActive = "active"
|
StatusActive = "active"
|
||||||
@ -24,6 +24,7 @@ type Tier struct {
|
|||||||
Pool string `json:"pool"`
|
Pool string `json:"pool"`
|
||||||
TierID string `json:"tier_id"`
|
TierID string `json:"tier_id"`
|
||||||
RewardCoins int64 `json:"reward_coins"`
|
RewardCoins int64 `json:"reward_coins"`
|
||||||
|
MultiplierPPM int64 `json:"multiplier_ppm"`
|
||||||
Weight int64 `json:"weight"`
|
Weight int64 `json:"weight"`
|
||||||
HighWaterOnly bool `json:"high_water_only"`
|
HighWaterOnly bool `json:"high_water_only"`
|
||||||
Enabled bool `json:"enabled"`
|
Enabled bool `json:"enabled"`
|
||||||
@ -31,47 +32,50 @@ type Tier struct {
|
|||||||
|
|
||||||
// Config 是幸运礼物线上规则快照。金额均为整数金币,比例均为 ppm。
|
// Config 是幸运礼物线上规则快照。金额均为整数金币,比例均为 ppm。
|
||||||
type Config struct {
|
type Config struct {
|
||||||
AppCode string `json:"app_code"`
|
AppCode string `json:"app_code"`
|
||||||
GiftID string `json:"gift_id"`
|
GiftID string `json:"gift_id"`
|
||||||
Enabled bool `json:"enabled"`
|
PoolID string `json:"pool_id"`
|
||||||
RuleVersion int64 `json:"rule_version"`
|
Enabled bool `json:"enabled"`
|
||||||
GiftPrice int64 `json:"gift_price"`
|
RuleVersion int64 `json:"rule_version"`
|
||||||
TargetRTPPPM int64 `json:"target_rtp_ppm"`
|
GiftPrice int64 `json:"gift_price"`
|
||||||
PoolRatePPM int64 `json:"pool_rate_ppm"`
|
TargetRTPPPM int64 `json:"target_rtp_ppm"`
|
||||||
GlobalWindowDraws int64 `json:"global_window_draws"`
|
PoolRatePPM int64 `json:"pool_rate_ppm"`
|
||||||
GiftWindowDraws int64 `json:"gift_window_draws"`
|
GlobalWindowDraws int64 `json:"global_window_draws"`
|
||||||
NoviceDrawLimit int64 `json:"novice_draw_limit"`
|
GiftWindowDraws int64 `json:"gift_window_draws"`
|
||||||
IntermediateDrawLimit int64 `json:"intermediate_draw_limit"`
|
NoviceDrawLimit int64 `json:"novice_draw_limit"`
|
||||||
HighMultiplier int64 `json:"high_multiplier"`
|
IntermediateDrawLimit int64 `json:"intermediate_draw_limit"`
|
||||||
HighWaterPoolMultiple int64 `json:"high_water_pool_multiple"`
|
HighMultiplier int64 `json:"high_multiplier"`
|
||||||
PlatformPoolWeightPPM int64 `json:"platform_pool_weight_ppm"`
|
HighWaterPoolMultiple int64 `json:"high_water_pool_multiple"`
|
||||||
RoomPoolWeightPPM int64 `json:"room_pool_weight_ppm"`
|
PlatformPoolWeightPPM int64 `json:"platform_pool_weight_ppm"`
|
||||||
GiftPoolWeightPPM int64 `json:"gift_pool_weight_ppm"`
|
RoomPoolWeightPPM int64 `json:"room_pool_weight_ppm"`
|
||||||
InitialPlatformPool int64 `json:"initial_platform_pool"`
|
GiftPoolWeightPPM int64 `json:"gift_pool_weight_ppm"`
|
||||||
InitialGiftPool int64 `json:"initial_gift_pool"`
|
InitialPlatformPool int64 `json:"initial_platform_pool"`
|
||||||
InitialRoomPool int64 `json:"initial_room_pool"`
|
InitialGiftPool int64 `json:"initial_gift_pool"`
|
||||||
PlatformReserve int64 `json:"platform_reserve"`
|
InitialRoomPool int64 `json:"initial_room_pool"`
|
||||||
GiftReserve int64 `json:"gift_reserve"`
|
PlatformReserve int64 `json:"platform_reserve"`
|
||||||
RoomReserve int64 `json:"room_reserve"`
|
GiftReserve int64 `json:"gift_reserve"`
|
||||||
MaxSinglePayout int64 `json:"max_single_payout"`
|
RoomReserve int64 `json:"room_reserve"`
|
||||||
UserHourlyPayoutCap int64 `json:"user_hourly_payout_cap"`
|
MaxSinglePayout int64 `json:"max_single_payout"`
|
||||||
UserDailyPayoutCap int64 `json:"user_daily_payout_cap"`
|
UserHourlyPayoutCap int64 `json:"user_hourly_payout_cap"`
|
||||||
DeviceDailyPayoutCap int64 `json:"device_daily_payout_cap"`
|
UserDailyPayoutCap int64 `json:"user_daily_payout_cap"`
|
||||||
RoomHourlyPayoutCap int64 `json:"room_hourly_payout_cap"`
|
DeviceDailyPayoutCap int64 `json:"device_daily_payout_cap"`
|
||||||
AnchorDailyPayoutCap int64 `json:"anchor_daily_payout_cap"`
|
RoomHourlyPayoutCap int64 `json:"room_hourly_payout_cap"`
|
||||||
RoomAtmosphereRatePPM int64 `json:"room_atmosphere_rate_ppm"`
|
AnchorDailyPayoutCap int64 `json:"anchor_daily_payout_cap"`
|
||||||
RoomAtmosphereInitial int64 `json:"room_atmosphere_initial"`
|
RoomAtmosphereRatePPM int64 `json:"room_atmosphere_rate_ppm"`
|
||||||
RoomAtmosphereReserve int64 `json:"room_atmosphere_reserve"`
|
RoomAtmosphereInitial int64 `json:"room_atmosphere_initial"`
|
||||||
ActivityBudget int64 `json:"activity_budget"`
|
RoomAtmosphereReserve int64 `json:"room_atmosphere_reserve"`
|
||||||
ActivityDailyLimit int64 `json:"activity_daily_limit"`
|
ActivityBudget int64 `json:"activity_budget"`
|
||||||
LargeTierEnabled bool `json:"large_tier_enabled"`
|
ActivityDailyLimit int64 `json:"activity_daily_limit"`
|
||||||
Tiers []Tier `json:"tiers"`
|
LargeTierEnabled bool `json:"large_tier_enabled"`
|
||||||
UpdatedByAdminID int64 `json:"updated_by_admin_id"`
|
MultiplierPPMs []int64 `json:"multiplier_ppms"`
|
||||||
CreatedAtMS int64 `json:"created_at_ms"`
|
Tiers []Tier `json:"tiers"`
|
||||||
UpdatedAtMS int64 `json:"updated_at_ms"`
|
UpdatedByAdminID int64 `json:"updated_by_admin_id"`
|
||||||
|
CreatedAtMS int64 `json:"created_at_ms"`
|
||||||
|
UpdatedAtMS int64 `json:"updated_at_ms"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type CheckCommand struct {
|
type CheckCommand struct {
|
||||||
|
PoolID string
|
||||||
GiftID string
|
GiftID string
|
||||||
UserID int64
|
UserID int64
|
||||||
RoomID string
|
RoomID string
|
||||||
@ -79,6 +83,7 @@ type CheckCommand struct {
|
|||||||
|
|
||||||
type DrawCommand struct {
|
type DrawCommand struct {
|
||||||
CommandID string
|
CommandID string
|
||||||
|
PoolID string
|
||||||
UserID int64
|
UserID int64
|
||||||
DeviceID string
|
DeviceID string
|
||||||
RoomID string
|
RoomID string
|
||||||
@ -91,6 +96,7 @@ type DrawCommand struct {
|
|||||||
type CheckResult struct {
|
type CheckResult struct {
|
||||||
Enabled bool
|
Enabled bool
|
||||||
Reason string
|
Reason string
|
||||||
|
PoolID string
|
||||||
GiftID string
|
GiftID string
|
||||||
GiftPrice int64
|
GiftPrice int64
|
||||||
RuleVersion int64
|
RuleVersion int64
|
||||||
@ -101,6 +107,7 @@ type CheckResult struct {
|
|||||||
type DrawResult struct {
|
type DrawResult struct {
|
||||||
DrawID string
|
DrawID string
|
||||||
CommandID string
|
CommandID string
|
||||||
|
PoolID string
|
||||||
GiftID string
|
GiftID string
|
||||||
RuleVersion int64
|
RuleVersion int64
|
||||||
ExperiencePool string
|
ExperiencePool string
|
||||||
@ -121,10 +128,11 @@ type DrawResult struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type AdminConfigQuery struct {
|
type AdminConfigQuery struct {
|
||||||
GiftID string
|
PoolID string
|
||||||
}
|
}
|
||||||
|
|
||||||
type DrawQuery struct {
|
type DrawQuery struct {
|
||||||
|
PoolID string
|
||||||
GiftID string
|
GiftID string
|
||||||
UserID int64
|
UserID int64
|
||||||
RoomID string
|
RoomID string
|
||||||
|
|||||||
@ -2,6 +2,7 @@ package luckygift
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"hyapp/pkg/xerr"
|
"hyapp/pkg/xerr"
|
||||||
@ -10,12 +11,14 @@ import (
|
|||||||
|
|
||||||
const ppmScale int64 = 1_000_000
|
const ppmScale int64 = 1_000_000
|
||||||
|
|
||||||
// DefaultConfig 给后台首屏提供整体规则草稿;真正生效必须由后台显式 enabled。
|
// DefaultConfig 给后台指定奖池提供规则草稿;真正生效必须由后台显式 enabled。
|
||||||
func DefaultConfig(appCode string) domain.Config {
|
func DefaultConfig(appCode, poolID string) domain.Config {
|
||||||
const giftPrice int64 = 10_000
|
const giftPrice int64 = 10_000
|
||||||
|
poolID = normalizePoolID(poolID)
|
||||||
return domain.Config{
|
return domain.Config{
|
||||||
AppCode: appCode,
|
AppCode: appCode,
|
||||||
GiftID: domain.GlobalConfigGiftID,
|
GiftID: poolID,
|
||||||
|
PoolID: poolID,
|
||||||
Enabled: false,
|
Enabled: false,
|
||||||
GiftPrice: giftPrice,
|
GiftPrice: giftPrice,
|
||||||
TargetRTPPPM: 950_000,
|
TargetRTPPPM: 950_000,
|
||||||
@ -47,14 +50,16 @@ func DefaultConfig(appCode string) domain.Config {
|
|||||||
ActivityBudget: 0,
|
ActivityBudget: 0,
|
||||||
ActivityDailyLimit: 0,
|
ActivityDailyLimit: 0,
|
||||||
LargeTierEnabled: true,
|
LargeTierEnabled: true,
|
||||||
|
MultiplierPPMs: defaultMultiplierPPMs(),
|
||||||
Tiers: defaultTiers(giftPrice),
|
Tiers: defaultTiers(giftPrice),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func validateConfig(config domain.Config) error {
|
func validateConfig(config domain.Config) error {
|
||||||
if config.GiftID != domain.GlobalConfigGiftID {
|
if normalizePoolID(config.PoolID) == "" {
|
||||||
return xerr.New(xerr.InvalidArgument, "lucky gift config scope is invalid")
|
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 {
|
if config.GiftPrice <= 0 || config.TargetRTPPPM <= 0 || config.TargetRTPPPM > ppmScale {
|
||||||
return xerr.New(xerr.InvalidArgument, "gift price or target RTP is invalid")
|
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 {
|
if len(config.Tiers) == 0 {
|
||||||
return xerr.New(xerr.InvalidArgument, "reward tiers are required")
|
return xerr.New(xerr.InvalidArgument, "reward tiers are required")
|
||||||
}
|
}
|
||||||
|
maxMultiplierPPM := int64(0)
|
||||||
for _, tier := range config.Tiers {
|
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))
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func normalizeTiers(tiers []domain.Tier, giftPrice int64) []domain.Tier {
|
func normalizeTiers(tiers []domain.Tier, multipliers []int64, giftPrice, highMultiplier int64) ([]domain.Tier, []int64) {
|
||||||
if len(tiers) == 0 {
|
multiplierPPMs := normalizeMultiplierPPMs(multipliers, tiers, giftPrice)
|
||||||
return defaultTiers(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))
|
if len(values) == 0 {
|
||||||
for _, tier := range tiers {
|
values = defaultMultiplierPPMs()
|
||||||
tier.Pool = strings.TrimSpace(tier.Pool)
|
}
|
||||||
tier.TierID = strings.TrimSpace(tier.TierID)
|
seen := map[int64]bool{0: true}
|
||||||
if tier.Pool == "" || tier.TierID == "" {
|
out := []int64{0}
|
||||||
|
for _, value := range values {
|
||||||
|
if value < 0 || seen[value] {
|
||||||
continue
|
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
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
func defaultTiers(cost int64) []domain.Tier {
|
func defaultMultiplierPPMs() []int64 {
|
||||||
return []domain.Tier{
|
return []int64{
|
||||||
{Pool: domain.PoolNovice, TierID: "none", RewardCoins: 0, Weight: 720_000, Enabled: true},
|
0,
|
||||||
{Pool: domain.PoolNovice, TierID: "novice_feedback_0_2x", RewardCoins: cost / 5, Weight: 90_000, Enabled: true},
|
200_000,
|
||||||
{Pool: domain.PoolNovice, TierID: "novice_rebate_0_5x", RewardCoins: cost / 2, Weight: 70_000, Enabled: true},
|
500_000,
|
||||||
{Pool: domain.PoolNovice, TierID: "novice_rebate_1x", RewardCoins: cost, Weight: 70_000, Enabled: true},
|
1_000_000,
|
||||||
{Pool: domain.PoolNovice, TierID: "novice_small_2x", RewardCoins: cost * 2, Weight: 35_000, Enabled: true},
|
2_000_000,
|
||||||
{Pool: domain.PoolNovice, TierID: "novice_small_5x", RewardCoins: cost * 5, Weight: 12_000, Enabled: true},
|
5_000_000,
|
||||||
{Pool: domain.PoolNovice, TierID: "novice_medium_10x", RewardCoins: cost * 10, Weight: 2_500, Enabled: true},
|
10_000_000,
|
||||||
{Pool: domain.PoolNovice, TierID: "novice_cap_20x", RewardCoins: cost * 20, Weight: 500, Enabled: true},
|
20_000_000,
|
||||||
|
50_000_000,
|
||||||
{Pool: domain.PoolIntermediate, TierID: "none", RewardCoins: 0, Weight: 830_000, Enabled: true},
|
100_000_000,
|
||||||
{Pool: domain.PoolIntermediate, TierID: "inter_rebate_0_5x", RewardCoins: cost / 2, Weight: 25_000, Enabled: true},
|
500_000_000,
|
||||||
{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 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
|
||||||
|
}
|
||||||
|
|||||||
@ -2,12 +2,10 @@ package luckygift
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
domain "hyapp/services/activity-service/internal/domain/luckygift"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestDefaultConfigPassesValidation(t *testing.T) {
|
func TestDefaultConfigPassesValidation(t *testing.T) {
|
||||||
config := DefaultConfig("hyapp")
|
config := DefaultConfig("hyapp", "pool_95")
|
||||||
config.Enabled = true
|
config.Enabled = true
|
||||||
|
|
||||||
if err := validateConfig(config); err != nil {
|
if err := validateConfig(config); err != nil {
|
||||||
@ -15,30 +13,40 @@ func TestDefaultConfigPassesValidation(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestValidateConfigRejectsGiftScopedRules(t *testing.T) {
|
func TestValidateConfigAcceptsIndependentPoolScope(t *testing.T) {
|
||||||
config := DefaultConfig("hyapp")
|
config := DefaultConfig("hyapp", "pool_98")
|
||||||
config.GiftID = "gift_500"
|
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 {
|
if err := validateConfig(config); err == nil {
|
||||||
t.Fatalf("expected gift scoped lucky gift config to be rejected")
|
t.Fatalf("expected max multiplier below RTP 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")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -12,7 +12,7 @@ import (
|
|||||||
|
|
||||||
// Repository 是幸运礼物唯一持久化边界;RTP、奖池、风控计数必须在同一个 MySQL 事务内修改。
|
// Repository 是幸运礼物唯一持久化边界;RTP、奖池、风控计数必须在同一个 MySQL 事务内修改。
|
||||||
type Repository interface {
|
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)
|
UpsertLuckyGiftConfig(ctx context.Context, config domain.Config, nowMS int64) (domain.Config, error)
|
||||||
CheckLuckyGift(ctx context.Context, cmd domain.CheckCommand) (domain.CheckResult, error)
|
CheckLuckyGift(ctx context.Context, cmd domain.CheckCommand) (domain.CheckResult, error)
|
||||||
ExecuteLuckyGiftDraw(ctx context.Context, cmd domain.DrawCommand, nowMS int64) (domain.DrawResult, 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 {
|
if err := s.requireRepository(); err != nil {
|
||||||
return domain.CheckResult{}, err
|
return domain.CheckResult{}, err
|
||||||
}
|
}
|
||||||
|
cmd.PoolID = normalizePoolID(cmd.PoolID)
|
||||||
cmd.GiftID = strings.TrimSpace(cmd.GiftID)
|
cmd.GiftID = strings.TrimSpace(cmd.GiftID)
|
||||||
cmd.RoomID = strings.TrimSpace(cmd.RoomID)
|
cmd.RoomID = strings.TrimSpace(cmd.RoomID)
|
||||||
if cmd.GiftID == "" || cmd.UserID <= 0 || 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
|
return domain.DrawResult{}, err
|
||||||
}
|
}
|
||||||
cmd.CommandID = strings.TrimSpace(cmd.CommandID)
|
cmd.CommandID = strings.TrimSpace(cmd.CommandID)
|
||||||
|
cmd.PoolID = normalizePoolID(cmd.PoolID)
|
||||||
cmd.GiftID = strings.TrimSpace(cmd.GiftID)
|
cmd.GiftID = strings.TrimSpace(cmd.GiftID)
|
||||||
cmd.DeviceID = strings.TrimSpace(cmd.DeviceID)
|
cmd.DeviceID = strings.TrimSpace(cmd.DeviceID)
|
||||||
cmd.RoomID = strings.TrimSpace(cmd.RoomID)
|
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())
|
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 {
|
if err := s.requireRepository(); err != nil {
|
||||||
return domain.Config{}, err
|
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 {
|
if err != nil {
|
||||||
return domain.Config{}, err
|
return domain.Config{}, err
|
||||||
}
|
}
|
||||||
if !exists {
|
if !exists {
|
||||||
return DefaultConfig(appcode.FromContext(ctx)), nil
|
return DefaultConfig(appcode.FromContext(ctx), poolID), nil
|
||||||
}
|
}
|
||||||
return config, nil
|
return config, nil
|
||||||
}
|
}
|
||||||
@ -84,8 +87,9 @@ func (s *Service) UpsertConfig(ctx context.Context, config domain.Config) (domai
|
|||||||
return domain.Config{}, err
|
return domain.Config{}, err
|
||||||
}
|
}
|
||||||
config.AppCode = appcode.FromContext(ctx)
|
config.AppCode = appcode.FromContext(ctx)
|
||||||
config.GiftID = domain.GlobalConfigGiftID
|
config.PoolID = normalizePoolID(config.PoolID)
|
||||||
config.Tiers = normalizeTiers(config.Tiers, config.GiftPrice)
|
config.GiftID = config.PoolID
|
||||||
|
config.Tiers, config.MultiplierPPMs = normalizeTiers(config.Tiers, config.MultiplierPPMs, config.GiftPrice, config.HighMultiplier)
|
||||||
if err := validateConfig(config); err != nil {
|
if err := validateConfig(config); err != nil {
|
||||||
return domain.Config{}, err
|
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 {
|
if err := s.requireRepository(); err != nil {
|
||||||
return nil, 0, err
|
return nil, 0, err
|
||||||
}
|
}
|
||||||
|
query.PoolID = normalizePoolID(query.PoolID)
|
||||||
query.GiftID = strings.TrimSpace(query.GiftID)
|
query.GiftID = strings.TrimSpace(query.GiftID)
|
||||||
query.RoomID = strings.TrimSpace(query.RoomID)
|
query.RoomID = strings.TrimSpace(query.RoomID)
|
||||||
query.Status = strings.TrimSpace(query.Status)
|
query.Status = strings.TrimSpace(query.Status)
|
||||||
|
|||||||
@ -8,34 +8,33 @@ import (
|
|||||||
domain "hyapp/services/activity-service/internal/domain/luckygift"
|
domain "hyapp/services/activity-service/internal/domain/luckygift"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestGetConfigReturnsGlobalDefault(t *testing.T) {
|
func TestGetConfigReturnsPoolDefault(t *testing.T) {
|
||||||
repository := &fakeLuckyGiftRepository{}
|
repository := &fakeLuckyGiftRepository{}
|
||||||
service := New(repository)
|
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 {
|
if err != nil {
|
||||||
t.Fatalf("GetConfig returned error: %v", err)
|
t.Fatalf("GetConfig returned error: %v", err)
|
||||||
}
|
}
|
||||||
if repository.getConfigCalls != 1 {
|
if repository.getConfigCalls != 1 {
|
||||||
t.Fatalf("expected repository to be called once, got %d", repository.getConfigCalls)
|
t.Fatalf("expected repository to be called once, got %d", repository.getConfigCalls)
|
||||||
}
|
}
|
||||||
if config.GiftID != domain.GlobalConfigGiftID {
|
if config.PoolID != "pool_95" || config.GiftID != "pool_95" {
|
||||||
t.Fatalf("expected global config scope, got %q", config.GiftID)
|
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{}
|
repository := &fakeLuckyGiftRepository{}
|
||||||
service := New(repository)
|
service := New(repository)
|
||||||
config := DefaultConfig("hyapp")
|
config := DefaultConfig("hyapp", "pool_98")
|
||||||
config.GiftID = "gift_500"
|
|
||||||
config.Enabled = true
|
config.Enabled = true
|
||||||
|
|
||||||
if _, err := service.UpsertConfig(appcode.WithContext(context.Background(), "hyapp"), config); err != nil {
|
if _, err := service.UpsertConfig(appcode.WithContext(context.Background(), "hyapp"), config); err != nil {
|
||||||
t.Fatalf("UpsertConfig returned error: %v", err)
|
t.Fatalf("UpsertConfig returned error: %v", err)
|
||||||
}
|
}
|
||||||
if repository.upserted.GiftID != domain.GlobalConfigGiftID {
|
if repository.upserted.PoolID != "pool_98" || repository.upserted.GiftID != "pool_98" {
|
||||||
t.Fatalf("expected global config scope, got %q", repository.upserted.GiftID)
|
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
|
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++
|
r.getConfigCalls++
|
||||||
return domain.Config{}, false, nil
|
return domain.Config{}, false, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@ -8,6 +8,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"math/big"
|
"math/big"
|
||||||
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@ -109,6 +110,7 @@ type luckyCandidate struct {
|
|||||||
Pool string
|
Pool string
|
||||||
Weight int64
|
Weight int64
|
||||||
BaseReward int64
|
BaseReward int64
|
||||||
|
MultiplierPPM int64
|
||||||
RoomReward int64
|
RoomReward int64
|
||||||
ActivityReward int64
|
ActivityReward int64
|
||||||
Presentation bool
|
Presentation bool
|
||||||
@ -120,12 +122,13 @@ func (c luckyCandidate) effectiveReward() int64 {
|
|||||||
return c.BaseReward + c.RoomReward + c.ActivityReward
|
return c.BaseReward + c.RoomReward + c.ActivityReward
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetLuckyGiftConfig 返回幸运礼物整体规则;未配置由用例层转为默认草稿。
|
// GetLuckyGiftConfig 返回指定幸运礼物奖池规则;未配置由用例层转为默认草稿。
|
||||||
func (r *Repository) GetLuckyGiftConfig(ctx context.Context) (domain.Config, bool, error) {
|
func (r *Repository) GetLuckyGiftConfig(ctx context.Context, poolID string) (domain.Config, bool, error) {
|
||||||
if r == nil || r.db == nil {
|
if r == nil || r.db == nil {
|
||||||
return domain.Config{}, false, xerr.New(xerr.Unavailable, "mysql repository is not configured")
|
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 发布新的不可变规则版本。运行中窗口不回退,新版本只影响后续抽奖。
|
// UpsertLuckyGiftConfig 发布新的不可变规则版本。运行中窗口不回退,新版本只影响后续抽奖。
|
||||||
@ -140,7 +143,8 @@ func (r *Repository) UpsertLuckyGiftConfig(ctx context.Context, config domain.Co
|
|||||||
defer func() { _ = tx.Rollback() }()
|
defer func() { _ = tx.Rollback() }()
|
||||||
|
|
||||||
appCode := appcode.FromContext(ctx)
|
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)
|
current, exists, err := r.getLuckyGiftConfig(ctx, tx, appCode, config.GiftID, true)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return domain.Config{}, err
|
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) {
|
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 {
|
if err != nil {
|
||||||
return domain.CheckResult{}, err
|
return domain.CheckResult{}, err
|
||||||
}
|
}
|
||||||
if !exists {
|
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)
|
paidDraws, err := r.getLuckyUserPaidDraws(ctx, appcode.FromContext(ctx), cmd.UserID, config.GiftID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -243,6 +248,7 @@ func (r *Repository) CheckLuckyGift(ctx context.Context, cmd domain.CheckCommand
|
|||||||
return domain.CheckResult{
|
return domain.CheckResult{
|
||||||
Enabled: config.Enabled,
|
Enabled: config.Enabled,
|
||||||
Reason: luckyEnabledReason(config.Enabled),
|
Reason: luckyEnabledReason(config.Enabled),
|
||||||
|
PoolID: config.GiftID,
|
||||||
GiftID: cmd.GiftID,
|
GiftID: cmd.GiftID,
|
||||||
GiftPrice: config.GiftPrice,
|
GiftPrice: config.GiftPrice,
|
||||||
RuleVersion: config.RuleVersion,
|
RuleVersion: config.RuleVersion,
|
||||||
@ -263,6 +269,7 @@ func (r *Repository) ExecuteLuckyGiftDraw(ctx context.Context, cmd domain.DrawCo
|
|||||||
defer func() { _ = tx.Rollback() }()
|
defer func() { _ = tx.Rollback() }()
|
||||||
|
|
||||||
appCode := appcode.FromContext(ctx)
|
appCode := appcode.FromContext(ctx)
|
||||||
|
poolID := luckyPoolID(cmd.PoolID)
|
||||||
if existing, exists, err := r.getLuckyDrawByCommand(ctx, tx, appCode, cmd.CommandID, true); err != nil || exists {
|
if existing, exists, err := r.getLuckyDrawByCommand(ctx, tx, appCode, cmd.CommandID, true); err != nil || exists {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return domain.DrawResult{}, err
|
return domain.DrawResult{}, err
|
||||||
@ -273,7 +280,7 @@ func (r *Repository) ExecuteLuckyGiftDraw(ctx context.Context, cmd domain.DrawCo
|
|||||||
return existing, nil
|
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 {
|
if err != nil {
|
||||||
return domain.DrawResult{}, err
|
return domain.DrawResult{}, err
|
||||||
}
|
}
|
||||||
@ -291,22 +298,22 @@ func (r *Repository) ExecuteLuckyGiftDraw(ctx context.Context, cmd domain.DrawCo
|
|||||||
stageFeedback := luckyStageFeedback(nextPaidDraw, config)
|
stageFeedback := luckyStageFeedback(nextPaidDraw, config)
|
||||||
minFutureMax := luckyMinFutureMax(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 {
|
if err != nil {
|
||||||
return domain.DrawResult{}, err
|
return domain.DrawResult{}, err
|
||||||
}
|
}
|
||||||
globalWindow = globalWindow.withCurrentWager(cmd.CoinSpent)
|
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 {
|
if err != nil {
|
||||||
return domain.DrawResult{}, err
|
return domain.DrawResult{}, err
|
||||||
}
|
}
|
||||||
giftWindow = giftWindow.withCurrentWager(cmd.CoinSpent)
|
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 {
|
if err != nil {
|
||||||
return domain.DrawResult{}, err
|
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 {
|
if err != nil {
|
||||||
return domain.DrawResult{}, err
|
return domain.DrawResult{}, err
|
||||||
}
|
}
|
||||||
@ -314,21 +321,21 @@ func (r *Repository) ExecuteLuckyGiftDraw(ctx context.Context, cmd domain.DrawCo
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return domain.DrawResult{}, err
|
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 {
|
if err != nil {
|
||||||
return domain.DrawResult{}, err
|
return domain.DrawResult{}, err
|
||||||
}
|
}
|
||||||
if err := r.creditLuckyBasePools(ctx, tx, appCode, config, cmd.CoinSpent, &platformPool, &roomPool, &giftPool, nowMS); err != nil {
|
if err := r.creditLuckyBasePools(ctx, tx, appCode, config, cmd.CoinSpent, &platformPool, &roomPool, &giftPool, nowMS); err != nil {
|
||||||
return domain.DrawResult{}, err
|
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
|
return domain.DrawResult{}, err
|
||||||
}
|
}
|
||||||
activityRemaining, activityDay, err := r.getLuckyActivityRemaining(ctx, tx, appCode, config, cmd.PaidAtMS, nowMS)
|
activityRemaining, activityDay, err := r.getLuckyActivityRemaining(ctx, tx, appCode, config, cmd.PaidAtMS, nowMS)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return domain.DrawResult{}, err
|
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 {
|
if err != nil {
|
||||||
return domain.DrawResult{}, err
|
return domain.DrawResult{}, err
|
||||||
}
|
}
|
||||||
@ -368,6 +375,7 @@ func (r *Repository) ExecuteLuckyGiftDraw(ctx context.Context, cmd domain.DrawCo
|
|||||||
return domain.DrawResult{
|
return domain.DrawResult{
|
||||||
DrawID: drawID,
|
DrawID: drawID,
|
||||||
CommandID: cmd.CommandID,
|
CommandID: cmd.CommandID,
|
||||||
|
PoolID: config.GiftID,
|
||||||
GiftID: cmd.GiftID,
|
GiftID: cmd.GiftID,
|
||||||
RuleVersion: config.RuleVersion,
|
RuleVersion: config.RuleVersion,
|
||||||
ExperiencePool: experiencePool,
|
ExperiencePool: experiencePool,
|
||||||
@ -438,10 +446,10 @@ func (r *Repository) applyLuckyDraw(ctx context.Context, tx *sql.Tx, input lucky
|
|||||||
); err != nil {
|
); err != nil {
|
||||||
return err
|
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
|
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
|
return err
|
||||||
}
|
}
|
||||||
if err := r.debitLuckyPool(ctx, tx, input.AppCode, "gift", input.GiftPool.ScopeID, giftOut, input.NowMS); err != nil {
|
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
|
UPDATE lucky_room_atmosphere_pools
|
||||||
SET balance = balance - ?, total_out = total_out + ?, updated_at_ms = ?
|
SET balance = balance - ?, total_out = total_out + ?, updated_at_ms = ?
|
||||||
WHERE app_code = ? AND room_id = ?`,
|
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 {
|
); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@ -478,7 +486,7 @@ func (r *Repository) applyLuckyDraw(ctx context.Context, tx *sql.Tx, input lucky
|
|||||||
return err
|
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
|
return err
|
||||||
}
|
}
|
||||||
if _, err := tx.ExecContext(ctx, `
|
if _, err := tx.ExecContext(ctx, `
|
||||||
@ -490,8 +498,11 @@ func (r *Repository) applyLuckyDraw(ctx context.Context, tx *sql.Tx, input lucky
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
candidateSnapshot, _ := json.Marshal(map[string]any{
|
candidateSnapshot, _ := json.Marshal(map[string]any{
|
||||||
"limited": input.Limited,
|
"base_reward": input.Candidate.BaseReward,
|
||||||
"source": input.Candidate.Source,
|
"limited": input.Limited,
|
||||||
|
"multiplier_ppm": input.Candidate.MultiplierPPM,
|
||||||
|
"source": input.Candidate.Source,
|
||||||
|
"weight": input.Candidate.Weight,
|
||||||
})
|
})
|
||||||
poolSnapshot, _ := json.Marshal(map[string]any{
|
poolSnapshot, _ := json.Marshal(map[string]any{
|
||||||
"platform_balance": input.PlatformPool.Balance,
|
"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, `
|
if _, err := tx.ExecContext(ctx, `
|
||||||
INSERT INTO lucky_draw_records (
|
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,
|
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,
|
selected_tier_id, base_reward_coins, room_atmosphere_reward_coins, activity_subsidy_coins,
|
||||||
effective_reward_coins, budget_sources_json, stage_feedback, high_multiplier,
|
effective_reward_coins, budget_sources_json, stage_feedback, high_multiplier,
|
||||||
candidate_tiers_json, pool_snapshot_json, rtp_snapshot_json, reward_status,
|
candidate_tiers_json, pool_snapshot_json, rtp_snapshot_json, reward_status,
|
||||||
paid_at_ms, created_at_ms, updated_at_ms
|
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.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.Config.RuleVersion, input.ExperiencePool, input.GlobalWindow.WindowIndex, input.GiftWindow.WindowIndex,
|
||||||
input.Candidate.TierID, input.Candidate.BaseReward, input.Candidate.RoomReward, input.Candidate.ActivityReward,
|
input.Candidate.TierID, input.Candidate.BaseReward, input.Candidate.RoomReward, input.Candidate.ActivityReward,
|
||||||
input.Candidate.effectiveReward(), input.BudgetSourcesJSON, input.StageFeedback, input.Candidate.HighMultiplier,
|
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 {
|
if input.Candidate.effectiveReward() > 0 || input.StageFeedback || input.Candidate.Presentation {
|
||||||
payload, _ := json.Marshal(map[string]any{
|
payload, _ := json.Marshal(map[string]any{
|
||||||
"draw_id": input.DrawID,
|
"draw_id": input.DrawID,
|
||||||
|
"pool_id": input.Config.GiftID,
|
||||||
"user_id": input.Command.UserID,
|
"user_id": input.Command.UserID,
|
||||||
"room_id": input.Command.RoomID,
|
"room_id": input.Command.RoomID,
|
||||||
"gift_id": input.Command.GiftID,
|
"gift_id": input.Command.GiftID,
|
||||||
@ -550,6 +562,10 @@ func (r *Repository) ListLuckyGiftDraws(ctx context.Context, query domain.DrawQu
|
|||||||
appCode := appcode.FromContext(ctx)
|
appCode := appcode.FromContext(ctx)
|
||||||
where := []string{"app_code = ?"}
|
where := []string{"app_code = ?"}
|
||||||
args := []any{appCode}
|
args := []any{appCode}
|
||||||
|
if query.PoolID != "" {
|
||||||
|
where = append(where, "pool_id = ?")
|
||||||
|
args = append(args, query.PoolID)
|
||||||
|
}
|
||||||
if query.GiftID != "" {
|
if query.GiftID != "" {
|
||||||
where = append(where, "gift_id = ?")
|
where = append(where, "gift_id = ?")
|
||||||
args = append(args, query.GiftID)
|
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)
|
offset := int((query.Page - 1) * query.PageSize)
|
||||||
args = append(args, limit, offset)
|
args = append(args, limit, offset)
|
||||||
rows, err := r.db.QueryContext(ctx, `
|
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,
|
base_reward_coins, room_atmosphere_reward_coins, activity_subsidy_coins,
|
||||||
effective_reward_coins, COALESCE(CAST(budget_sources_json AS CHAR), ''), reward_status,
|
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
|
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)
|
items := make([]domain.DrawResult, 0, limit)
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var item domain.DrawResult
|
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.BaseRewardCoins, &item.RoomAtmosphereRewardCoins, &item.ActivitySubsidyCoins, &item.EffectiveRewardCoins,
|
||||||
&item.BudgetSourcesJSON, &item.RewardStatus, &item.RTPWindowIndex, &item.GiftRTPWindowIndex,
|
&item.BudgetSourcesJSON, &item.RewardStatus, &item.RTPWindowIndex, &item.GiftRTPWindowIndex,
|
||||||
&item.StageFeedback, &item.HighMultiplier, &item.CreatedAtMS); err != nil {
|
&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 {
|
if err := json.Unmarshal([]byte(tiersJSON), &config.Tiers); err != nil {
|
||||||
return domain.Config{}, false, err
|
return domain.Config{}, false, err
|
||||||
}
|
}
|
||||||
|
config.PoolID = config.GiftID
|
||||||
|
config.MultiplierPPMs = luckyMultiplierPPMsFromTiers(config.Tiers, config.GiftPrice)
|
||||||
return config, true, nil
|
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 {
|
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
|
poolIn := spent * config.PoolRatePPM / luckyPPMScale
|
||||||
platformIn, roomIn, giftIn := luckySplitWeighted(poolIn, config)
|
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
|
return err
|
||||||
}
|
}
|
||||||
if err := r.creditLuckyPool(ctx, tx, appCode, "room", room.ScopeID, roomIn, room, nowMS); err != nil {
|
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
|
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")
|
hour := time.UnixMilli(cmd.PaidAtMS).UTC().Format("2006-01-02T15")
|
||||||
day := time.UnixMilli(cmd.PaidAtMS).UTC().Format("2006-01-02")
|
day := time.UnixMilli(cmd.PaidAtMS).UTC().Format("2006-01-02")
|
||||||
specs := []luckyCounter{
|
specs := []luckyCounter{
|
||||||
{ScopeType: "user", ScopeID: fmt.Sprintf("%d", cmd.UserID), WindowType: "hour", BucketKey: hour},
|
{ScopeType: "user", ScopeID: luckyPoolScopedID(poolID, fmt.Sprintf("%d", cmd.UserID)), WindowType: "hour", BucketKey: hour},
|
||||||
{ScopeType: "user", ScopeID: fmt.Sprintf("%d", cmd.UserID), WindowType: "day", BucketKey: day},
|
{ScopeType: "user", ScopeID: luckyPoolScopedID(poolID, fmt.Sprintf("%d", cmd.UserID)), WindowType: "day", BucketKey: day},
|
||||||
{ScopeType: "device", ScopeID: cmd.DeviceID, WindowType: "day", BucketKey: day},
|
{ScopeType: "device", ScopeID: luckyPoolScopedID(poolID, cmd.DeviceID), WindowType: "day", BucketKey: day},
|
||||||
{ScopeType: "room", ScopeID: cmd.RoomID, WindowType: "hour", BucketKey: hour},
|
{ScopeType: "room", ScopeID: luckyPoolScopedID(poolID, cmd.RoomID), WindowType: "hour", BucketKey: hour},
|
||||||
{ScopeType: "anchor", ScopeID: cmd.AnchorID, WindowType: "day", BucketKey: day},
|
{ScopeType: "anchor", ScopeID: luckyPoolScopedID(poolID, cmd.AnchorID), WindowType: "day", BucketKey: day},
|
||||||
}
|
}
|
||||||
out := make(map[string]luckyCounter, len(specs))
|
out := make(map[string]luckyCounter, len(specs))
|
||||||
for _, spec := range specs {
|
for _, spec := range specs {
|
||||||
@ -1014,18 +1032,18 @@ func (r *Repository) getOrCreateLuckyRiskCounter(ctx context.Context, tx *sql.Tx
|
|||||||
return spec, nil
|
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 {
|
if payout <= 0 {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
hour := time.UnixMilli(cmd.PaidAtMS).UTC().Format("2006-01-02T15")
|
hour := time.UnixMilli(cmd.PaidAtMS).UTC().Format("2006-01-02T15")
|
||||||
day := time.UnixMilli(cmd.PaidAtMS).UTC().Format("2006-01-02")
|
day := time.UnixMilli(cmd.PaidAtMS).UTC().Format("2006-01-02")
|
||||||
specs := []luckyCounter{
|
specs := []luckyCounter{
|
||||||
{ScopeType: "user", ScopeID: fmt.Sprintf("%d", cmd.UserID), WindowType: "hour", BucketKey: hour},
|
{ScopeType: "user", ScopeID: luckyPoolScopedID(poolID, fmt.Sprintf("%d", cmd.UserID)), WindowType: "hour", BucketKey: hour},
|
||||||
{ScopeType: "user", ScopeID: fmt.Sprintf("%d", cmd.UserID), WindowType: "day", BucketKey: day},
|
{ScopeType: "user", ScopeID: luckyPoolScopedID(poolID, fmt.Sprintf("%d", cmd.UserID)), WindowType: "day", BucketKey: day},
|
||||||
{ScopeType: "device", ScopeID: cmd.DeviceID, WindowType: "day", BucketKey: day},
|
{ScopeType: "device", ScopeID: luckyPoolScopedID(poolID, cmd.DeviceID), WindowType: "day", BucketKey: day},
|
||||||
{ScopeType: "room", ScopeID: cmd.RoomID, WindowType: "hour", BucketKey: hour},
|
{ScopeType: "room", ScopeID: luckyPoolScopedID(poolID, cmd.RoomID), WindowType: "hour", BucketKey: hour},
|
||||||
{ScopeType: "anchor", ScopeID: cmd.AnchorID, WindowType: "day", BucketKey: day},
|
{ScopeType: "anchor", ScopeID: luckyPoolScopedID(poolID, cmd.AnchorID), WindowType: "day", BucketKey: day},
|
||||||
}
|
}
|
||||||
for _, spec := range specs {
|
for _, spec := range specs {
|
||||||
if _, err := tx.ExecContext(ctx, `
|
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))
|
baseMaxAllowed := minInt64(rtpMaxAllowed, minInt64(poolCapacity, riskCapacity))
|
||||||
limited := map[string]bool{}
|
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 totalWeight int64
|
||||||
var disabledWeight int64
|
|
||||||
for _, tier := range config.Tiers {
|
for _, tier := range config.Tiers {
|
||||||
if tier.Pool != pool || !tier.Enabled || tier.Weight <= 0 {
|
if tier.Pool != pool || !tier.Enabled {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if tier.HighWaterOnly && !config.LargeTierEnabled {
|
if tier.RewardCoins == 0 {
|
||||||
if minRequired == 0 {
|
hasZeroCandidate = true
|
||||||
disabledWeight += tier.Weight
|
}
|
||||||
}
|
highMultiplier := tier.HighWaterOnly || tier.RewardCoins >= config.GiftPrice*config.HighMultiplier
|
||||||
|
if highMultiplier && !config.LargeTierEnabled {
|
||||||
limited["large_tier_disabled"] = true
|
limited["large_tier_disabled"] = true
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@ -1072,32 +1091,27 @@ func (r *Repository) selectLuckyCandidate(config domain.Config, pool string, sta
|
|||||||
if tier.RewardCoins > poolCapacity {
|
if tier.RewardCoins > poolCapacity {
|
||||||
limited["pool_cap"] = true
|
limited["pool_cap"] = true
|
||||||
}
|
}
|
||||||
if minRequired == 0 {
|
|
||||||
disabledWeight += tier.Weight
|
|
||||||
}
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
highMultiplier := tier.HighWaterOnly || tier.RewardCoins >= config.GiftPrice*config.HighMultiplier
|
|
||||||
if highMultiplier && !luckyHasHighWater(tier.RewardCoins, globalWindow, giftWindow, poolCapacity, config) {
|
if highMultiplier && !luckyHasHighWater(tier.RewardCoins, globalWindow, giftWindow, poolCapacity, config) {
|
||||||
limited["high_water"] = true
|
limited["high_water"] = true
|
||||||
if minRequired == 0 {
|
|
||||||
disabledWeight += tier.Weight
|
|
||||||
}
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
candidates = append(candidates, luckyCandidate{
|
baseCandidates = append(baseCandidates, luckyCandidate{
|
||||||
TierID: tier.TierID,
|
TierID: tier.TierID,
|
||||||
Source: domain.SourceBaseRTP,
|
Source: domain.SourceBaseRTP,
|
||||||
Pool: pool,
|
Pool: pool,
|
||||||
Weight: tier.Weight,
|
|
||||||
BaseReward: tier.RewardCoins,
|
BaseReward: tier.RewardCoins,
|
||||||
|
MultiplierPPM: tier.MultiplierPPM,
|
||||||
HighMultiplier: highMultiplier,
|
HighMultiplier: highMultiplier,
|
||||||
})
|
})
|
||||||
totalWeight += tier.Weight
|
|
||||||
}
|
}
|
||||||
if disabledWeight > 0 && minRequired == 0 {
|
if minRequired == 0 && !hasZeroCandidate {
|
||||||
candidates = append(candidates, luckyCandidate{TierID: "disabled_to_none", Source: domain.SourceBaseRTP, Pool: pool, Weight: disabledWeight})
|
baseCandidates = append(baseCandidates, luckyCandidate{TierID: pool + "_none", Source: domain.SourceBaseRTP, Pool: pool})
|
||||||
totalWeight += disabledWeight
|
}
|
||||||
|
candidates := luckyAutoWeightedBaseCandidates(baseCandidates, minRequired, globalWindow, giftWindow)
|
||||||
|
for _, candidate := range candidates {
|
||||||
|
totalWeight += candidate.Weight
|
||||||
}
|
}
|
||||||
if minRequired == 0 {
|
if minRequired == 0 {
|
||||||
if atmosphere.capacity() >= config.GiftPrice && riskCapacity >= config.GiftPrice {
|
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"
|
lockSQL = " FOR UPDATE"
|
||||||
}
|
}
|
||||||
row := tx.QueryRowContext(ctx, `
|
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,
|
base_reward_coins, room_atmosphere_reward_coins, activity_subsidy_coins,
|
||||||
effective_reward_coins, COALESCE(CAST(budget_sources_json AS CHAR), ''), reward_status,
|
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
|
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,
|
appCode, commandID,
|
||||||
)
|
)
|
||||||
var item domain.DrawResult
|
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.BaseRewardCoins, &item.RoomAtmosphereRewardCoins, &item.ActivitySubsidyCoins, &item.EffectiveRewardCoins,
|
||||||
&item.BudgetSourcesJSON, &item.RewardStatus, &item.RTPWindowIndex, &item.GiftRTPWindowIndex,
|
&item.BudgetSourcesJSON, &item.RewardStatus, &item.RTPWindowIndex, &item.GiftRTPWindowIndex,
|
||||||
&item.StageFeedback, &item.HighMultiplier, &item.CreatedAtMS); err != nil {
|
&item.StageFeedback, &item.HighMultiplier, &item.CreatedAtMS); err != nil {
|
||||||
@ -1245,6 +1259,90 @@ func luckyMinFutureMax(config domain.Config) int64 {
|
|||||||
return minMax
|
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) {
|
func luckySplitWeighted(amount int64, config domain.Config) (int64, int64, int64) {
|
||||||
platform := amount * config.PlatformPoolWeightPPM / luckyPPMScale
|
platform := amount * config.PlatformPoolWeightPPM / luckyPPMScale
|
||||||
room := amount * config.RoomPoolWeightPPM / luckyPPMScale
|
room := amount * config.RoomPoolWeightPPM / luckyPPMScale
|
||||||
@ -1286,6 +1384,39 @@ func luckyCounterKey(scopeType, windowType string) string {
|
|||||||
return scopeType + ":" + windowType
|
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 {
|
func luckyHasHighWater(reward int64, globalWindow, giftWindow luckyRTPWindow, poolCapacity int64, config domain.Config) bool {
|
||||||
if globalWindow.highWaterHeadroom(config.GiftPrice) < reward || giftWindow.highWaterHeadroom(config.GiftPrice) < reward {
|
if globalWindow.highWaterHeadroom(config.GiftPrice) < reward || giftWindow.highWaterHeadroom(config.GiftPrice) < reward {
|
||||||
return false
|
return false
|
||||||
|
|||||||
@ -8,7 +8,8 @@ import (
|
|||||||
|
|
||||||
func TestLuckyRuntimeConfigScalesTierRewardsByActualSpend(t *testing.T) {
|
func TestLuckyRuntimeConfigScalesTierRewardsByActualSpend(t *testing.T) {
|
||||||
config := domain.Config{
|
config := domain.Config{
|
||||||
GiftID: domain.GlobalConfigGiftID,
|
GiftID: domain.DefaultPoolID,
|
||||||
|
PoolID: domain.DefaultPoolID,
|
||||||
GiftPrice: 500,
|
GiftPrice: 500,
|
||||||
MaxSinglePayout: 250_000,
|
MaxSinglePayout: 250_000,
|
||||||
UserHourlyPayoutCap: 500_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)
|
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])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -24,6 +24,7 @@ func NewLuckyGiftServer(svc *service.Service) *LuckyGiftServer {
|
|||||||
func (s *LuckyGiftServer) CheckLuckyGift(ctx context.Context, req *activityv1.CheckLuckyGiftRequest) (*activityv1.CheckLuckyGiftResponse, error) {
|
func (s *LuckyGiftServer) CheckLuckyGift(ctx context.Context, req *activityv1.CheckLuckyGiftRequest) (*activityv1.CheckLuckyGiftResponse, error) {
|
||||||
ctx = appcode.WithContext(ctx, req.GetMeta().GetAppCode())
|
ctx = appcode.WithContext(ctx, req.GetMeta().GetAppCode())
|
||||||
result, err := s.svc.Check(ctx, domain.CheckCommand{
|
result, err := s.svc.Check(ctx, domain.CheckCommand{
|
||||||
|
PoolID: req.GetPoolId(),
|
||||||
GiftID: req.GetGiftId(),
|
GiftID: req.GetGiftId(),
|
||||||
UserID: req.GetUserId(),
|
UserID: req.GetUserId(),
|
||||||
RoomID: req.GetRoomId(),
|
RoomID: req.GetRoomId(),
|
||||||
@ -34,6 +35,7 @@ func (s *LuckyGiftServer) CheckLuckyGift(ctx context.Context, req *activityv1.Ch
|
|||||||
return &activityv1.CheckLuckyGiftResponse{
|
return &activityv1.CheckLuckyGiftResponse{
|
||||||
Enabled: result.Enabled,
|
Enabled: result.Enabled,
|
||||||
Reason: result.Reason,
|
Reason: result.Reason,
|
||||||
|
PoolId: result.PoolID,
|
||||||
GiftId: result.GiftID,
|
GiftId: result.GiftID,
|
||||||
GiftPrice: result.GiftPrice,
|
GiftPrice: result.GiftPrice,
|
||||||
RuleVersion: result.RuleVersion,
|
RuleVersion: result.RuleVersion,
|
||||||
@ -47,6 +49,7 @@ func (s *LuckyGiftServer) ExecuteLuckyGiftDraw(ctx context.Context, req *activit
|
|||||||
ctx = appcode.WithContext(ctx, meta.GetMeta().GetAppCode())
|
ctx = appcode.WithContext(ctx, meta.GetMeta().GetAppCode())
|
||||||
result, err := s.svc.Draw(ctx, domain.DrawCommand{
|
result, err := s.svc.Draw(ctx, domain.DrawCommand{
|
||||||
CommandID: meta.GetCommandId(),
|
CommandID: meta.GetCommandId(),
|
||||||
|
PoolID: meta.GetPoolId(),
|
||||||
UserID: meta.GetUserId(),
|
UserID: meta.GetUserId(),
|
||||||
DeviceID: meta.GetDeviceId(),
|
DeviceID: meta.GetDeviceId(),
|
||||||
RoomID: meta.GetRoomId(),
|
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) {
|
func (s *AdminLuckyGiftServer) GetLuckyGiftConfig(ctx context.Context, req *activityv1.GetLuckyGiftConfigRequest) (*activityv1.GetLuckyGiftConfigResponse, error) {
|
||||||
ctx = appcode.WithContext(ctx, req.GetMeta().GetAppCode())
|
ctx = appcode.WithContext(ctx, req.GetMeta().GetAppCode())
|
||||||
config, err := s.svc.GetConfig(ctx)
|
config, err := s.svc.GetConfig(ctx, req.GetPoolId())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, xerr.ToGRPCError(err)
|
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())
|
ctx = appcode.WithContext(ctx, req.GetMeta().GetAppCode())
|
||||||
draws, total, err := s.svc.ListDraws(ctx, domain.DrawQuery{
|
draws, total, err := s.svc.ListDraws(ctx, domain.DrawQuery{
|
||||||
GiftID: req.GetGiftId(),
|
GiftID: req.GetGiftId(),
|
||||||
|
PoolID: req.GetPoolId(),
|
||||||
UserID: req.GetUserId(),
|
UserID: req.GetUserId(),
|
||||||
RoomID: req.GetRoomId(),
|
RoomID: req.GetRoomId(),
|
||||||
Status: req.GetStatus(),
|
Status: req.GetStatus(),
|
||||||
@ -122,6 +126,7 @@ func luckyConfigFromProto(config *activityv1.LuckyGiftConfig) domain.Config {
|
|||||||
Pool: tier.GetPool(),
|
Pool: tier.GetPool(),
|
||||||
TierID: tier.GetTierId(),
|
TierID: tier.GetTierId(),
|
||||||
RewardCoins: tier.GetRewardCoins(),
|
RewardCoins: tier.GetRewardCoins(),
|
||||||
|
MultiplierPPM: tier.GetMultiplierPpm(),
|
||||||
Weight: tier.GetWeight(),
|
Weight: tier.GetWeight(),
|
||||||
HighWaterOnly: tier.GetHighWaterOnly(),
|
HighWaterOnly: tier.GetHighWaterOnly(),
|
||||||
Enabled: tier.GetEnabled(),
|
Enabled: tier.GetEnabled(),
|
||||||
@ -130,6 +135,7 @@ func luckyConfigFromProto(config *activityv1.LuckyGiftConfig) domain.Config {
|
|||||||
return domain.Config{
|
return domain.Config{
|
||||||
AppCode: config.GetAppCode(),
|
AppCode: config.GetAppCode(),
|
||||||
GiftID: config.GetGiftId(),
|
GiftID: config.GetGiftId(),
|
||||||
|
PoolID: config.GetPoolId(),
|
||||||
Enabled: config.GetEnabled(),
|
Enabled: config.GetEnabled(),
|
||||||
RuleVersion: config.GetRuleVersion(),
|
RuleVersion: config.GetRuleVersion(),
|
||||||
GiftPrice: config.GetGiftPrice(),
|
GiftPrice: config.GetGiftPrice(),
|
||||||
@ -162,6 +168,7 @@ func luckyConfigFromProto(config *activityv1.LuckyGiftConfig) domain.Config {
|
|||||||
ActivityBudget: config.GetActivityBudget(),
|
ActivityBudget: config.GetActivityBudget(),
|
||||||
ActivityDailyLimit: config.GetActivityDailyLimit(),
|
ActivityDailyLimit: config.GetActivityDailyLimit(),
|
||||||
LargeTierEnabled: config.GetLargeTierEnabled(),
|
LargeTierEnabled: config.GetLargeTierEnabled(),
|
||||||
|
MultiplierPPMs: append([]int64(nil), config.GetMultiplierPpms()...),
|
||||||
Tiers: tiers,
|
Tiers: tiers,
|
||||||
UpdatedByAdminID: config.GetUpdatedByAdminId(),
|
UpdatedByAdminID: config.GetUpdatedByAdminId(),
|
||||||
CreatedAtMS: config.GetCreatedAtMs(),
|
CreatedAtMS: config.GetCreatedAtMs(),
|
||||||
@ -176,6 +183,7 @@ func luckyConfigToProto(config domain.Config) *activityv1.LuckyGiftConfig {
|
|||||||
Pool: tier.Pool,
|
Pool: tier.Pool,
|
||||||
TierId: tier.TierID,
|
TierId: tier.TierID,
|
||||||
RewardCoins: tier.RewardCoins,
|
RewardCoins: tier.RewardCoins,
|
||||||
|
MultiplierPpm: tier.MultiplierPPM,
|
||||||
Weight: tier.Weight,
|
Weight: tier.Weight,
|
||||||
HighWaterOnly: tier.HighWaterOnly,
|
HighWaterOnly: tier.HighWaterOnly,
|
||||||
Enabled: tier.Enabled,
|
Enabled: tier.Enabled,
|
||||||
@ -184,6 +192,7 @@ func luckyConfigToProto(config domain.Config) *activityv1.LuckyGiftConfig {
|
|||||||
return &activityv1.LuckyGiftConfig{
|
return &activityv1.LuckyGiftConfig{
|
||||||
AppCode: config.AppCode,
|
AppCode: config.AppCode,
|
||||||
GiftId: config.GiftID,
|
GiftId: config.GiftID,
|
||||||
|
PoolId: config.PoolID,
|
||||||
Enabled: config.Enabled,
|
Enabled: config.Enabled,
|
||||||
RuleVersion: config.RuleVersion,
|
RuleVersion: config.RuleVersion,
|
||||||
GiftPrice: config.GiftPrice,
|
GiftPrice: config.GiftPrice,
|
||||||
@ -216,6 +225,7 @@ func luckyConfigToProto(config domain.Config) *activityv1.LuckyGiftConfig {
|
|||||||
ActivityBudget: config.ActivityBudget,
|
ActivityBudget: config.ActivityBudget,
|
||||||
ActivityDailyLimit: config.ActivityDailyLimit,
|
ActivityDailyLimit: config.ActivityDailyLimit,
|
||||||
LargeTierEnabled: config.LargeTierEnabled,
|
LargeTierEnabled: config.LargeTierEnabled,
|
||||||
|
MultiplierPpms: append([]int64(nil), config.MultiplierPPMs...),
|
||||||
Tiers: tiers,
|
Tiers: tiers,
|
||||||
UpdatedByAdminId: config.UpdatedByAdminID,
|
UpdatedByAdminId: config.UpdatedByAdminID,
|
||||||
CreatedAtMs: config.CreatedAtMS,
|
CreatedAtMs: config.CreatedAtMS,
|
||||||
@ -227,6 +237,7 @@ func luckyDrawResultToProto(result domain.DrawResult) *activityv1.LuckyGiftDrawR
|
|||||||
return &activityv1.LuckyGiftDrawResult{
|
return &activityv1.LuckyGiftDrawResult{
|
||||||
DrawId: result.DrawID,
|
DrawId: result.DrawID,
|
||||||
CommandId: result.CommandID,
|
CommandId: result.CommandID,
|
||||||
|
PoolId: result.PoolID,
|
||||||
GiftId: result.GiftID,
|
GiftId: result.GiftID,
|
||||||
RuleVersion: result.RuleVersion,
|
RuleVersion: result.RuleVersion,
|
||||||
ExperiencePool: result.ExperiencePool,
|
ExperiencePool: result.ExperiencePool,
|
||||||
|
|||||||
@ -12,6 +12,7 @@ CREATE TABLE IF NOT EXISTS game_platforms (
|
|||||||
platform_name VARCHAR(128) NOT NULL COMMENT '平台名称',
|
platform_name VARCHAR(128) NOT NULL COMMENT '平台名称',
|
||||||
status VARCHAR(32) NOT NULL COMMENT '业务状态',
|
status VARCHAR(32) NOT NULL COMMENT '业务状态',
|
||||||
api_base_url VARCHAR(512) NOT NULL DEFAULT '' COMMENT 'API 基准 URL',
|
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_secret_ciphertext VARCHAR(1024) NOT NULL DEFAULT '' COMMENT '回调密钥密文',
|
||||||
callback_ip_whitelist JSON NULL COMMENT '回调IP白名单',
|
callback_ip_whitelist JSON NULL COMMENT '回调IP白名单',
|
||||||
adapter_config JSON NULL COMMENT '适配器配置',
|
adapter_config JSON NULL COMMENT '适配器配置',
|
||||||
|
|||||||
@ -10,11 +10,17 @@ const (
|
|||||||
LaunchModeH5Popup = "h5_popup"
|
LaunchModeH5Popup = "h5_popup"
|
||||||
SessionActive = "active"
|
SessionActive = "active"
|
||||||
|
|
||||||
|
AdapterDemo = "demo"
|
||||||
|
AdapterYomiV4 = "yomi_v4"
|
||||||
|
AdapterLeaderCCV1 = "leadercc_v1"
|
||||||
|
|
||||||
OrderStatusWalletApplying = "wallet_applying"
|
OrderStatusWalletApplying = "wallet_applying"
|
||||||
OrderStatusSucceeded = "succeeded"
|
OrderStatusSucceeded = "succeeded"
|
||||||
OrderStatusFailed = "failed"
|
OrderStatusFailed = "failed"
|
||||||
OrderStatusConflict = "conflict"
|
OrderStatusConflict = "conflict"
|
||||||
|
|
||||||
|
RepairStatusLogged = "logged"
|
||||||
|
|
||||||
LevelEventStatusPending = "pending"
|
LevelEventStatusPending = "pending"
|
||||||
LevelEventStatusRunning = "running"
|
LevelEventStatusRunning = "running"
|
||||||
LevelEventStatusDelivered = "delivered"
|
LevelEventStatusDelivered = "delivered"
|
||||||
@ -23,14 +29,19 @@ const (
|
|||||||
|
|
||||||
// Platform 是第三方游戏平台配置事实。
|
// Platform 是第三方游戏平台配置事实。
|
||||||
type Platform struct {
|
type Platform struct {
|
||||||
AppCode string
|
AppCode string
|
||||||
PlatformCode string
|
PlatformCode string
|
||||||
PlatformName string
|
PlatformName string
|
||||||
Status string
|
Status string
|
||||||
APIBaseURL string
|
APIBaseURL string
|
||||||
SortOrder int32
|
AdapterType string
|
||||||
CreatedAtMS int64
|
CallbackSecretCiphertext string
|
||||||
UpdatedAtMS int64
|
CallbackSecretConfigured bool
|
||||||
|
CallbackIPWhitelist []string
|
||||||
|
AdapterConfigJSON string
|
||||||
|
SortOrder int32
|
||||||
|
CreatedAtMS int64
|
||||||
|
UpdatedAtMS int64
|
||||||
}
|
}
|
||||||
|
|
||||||
// CatalogItem 是内部稳定 game_id 到第三方 provider_game_id 的映射。
|
// CatalogItem 是内部稳定 game_id 到第三方 provider_game_id 的映射。
|
||||||
@ -73,9 +84,13 @@ type AppGame struct {
|
|||||||
// LaunchableGame 汇总启动时需要的平台和目录配置。
|
// LaunchableGame 汇总启动时需要的平台和目录配置。
|
||||||
type LaunchableGame struct {
|
type LaunchableGame struct {
|
||||||
CatalogItem
|
CatalogItem
|
||||||
PlatformName string
|
PlatformName string
|
||||||
PlatformStatus string
|
PlatformStatus string
|
||||||
APIBaseURL string
|
APIBaseURL string
|
||||||
|
AdapterType string
|
||||||
|
CallbackSecretCiphertext string
|
||||||
|
CallbackIPWhitelist []string
|
||||||
|
AdapterConfigJSON string
|
||||||
}
|
}
|
||||||
|
|
||||||
// LaunchSession 是 H5 启动会话持久事实,token 只保存 hash。
|
// LaunchSession 是 H5 启动会话持久事实,token 只保存 hash。
|
||||||
@ -154,3 +169,19 @@ type CallbackLog struct {
|
|||||||
Status string
|
Status string
|
||||||
CreatedAtMS int64
|
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
|
||||||
|
}
|
||||||
|
|||||||
283
services/game-service/internal/service/game/leadercc_adapter.go
Normal file
283
services/game-service/internal/service/game/leadercc_adapter.go
Normal file
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -29,11 +29,14 @@ type Repository interface {
|
|||||||
Ping(ctx context.Context) error
|
Ping(ctx context.Context) error
|
||||||
ListGames(ctx context.Context, query ListGamesQuery) ([]gamedomain.AppGame, error)
|
ListGames(ctx context.Context, query ListGamesQuery) ([]gamedomain.AppGame, error)
|
||||||
GetLaunchableGame(ctx context.Context, appCode string, gameID string) (gamedomain.LaunchableGame, 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
|
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
|
InsertCallbackLog(ctx context.Context, log gamedomain.CallbackLog) error
|
||||||
CreateGameOrder(ctx context.Context, order gamedomain.GameOrder) (gamedomain.GameOrder, bool, 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
|
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
|
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)
|
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
|
MarkLevelEventDelivered(ctx context.Context, eventID string, nowMS int64) error
|
||||||
MarkLevelEventFailed(ctx context.Context, eventID string, failureReason string, nextRetryAtMS int64, 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()
|
now := s.now()
|
||||||
sessionID := "game_sess_" + strconv.FormatInt(now.UnixMilli(), 10) + "_" + randomHex(6)
|
sessionID := "game_sess_" + strconv.FormatInt(now.UnixMilli(), 10) + "_" + randomHex(6)
|
||||||
token := "gt_" + randomHex(24)
|
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{
|
session := gamedomain.LaunchSession{
|
||||||
AppCode: command.AppCode,
|
AppCode: command.AppCode,
|
||||||
SessionID: sessionID,
|
SessionID: sessionID,
|
||||||
@ -229,7 +238,24 @@ func (s *Service) HandleCallback(ctx context.Context, req *gamev1.CallbackReques
|
|||||||
requestHash := stableHashBytes(req.GetRawBody())
|
requestHash := stableHashBytes(req.GetRawBody())
|
||||||
callbackID := "gcb_" + stableHash(fmt.Sprintf("%s|%s|%s|%d", app, req.GetPlatformCode(), requestHash, s.now().UnixNano()))
|
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)
|
responseHash := stableHashBytes(responseBody)
|
||||||
logStatus := "succeeded"
|
logStatus := "succeeded"
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -239,17 +265,18 @@ func (s *Service) HandleCallback(ctx context.Context, req *gamev1.CallbackReques
|
|||||||
responseHash = stableHashBytes(responseBody)
|
responseHash = stableHashBytes(responseBody)
|
||||||
}
|
}
|
||||||
_ = s.repository.InsertCallbackLog(ctx, gamedomain.CallbackLog{
|
_ = s.repository.InsertCallbackLog(ctx, gamedomain.CallbackLog{
|
||||||
AppCode: app,
|
AppCode: app,
|
||||||
CallbackID: callbackID,
|
CallbackID: callbackID,
|
||||||
PlatformCode: strings.TrimSpace(req.GetPlatformCode()),
|
PlatformCode: strings.TrimSpace(req.GetPlatformCode()),
|
||||||
Operation: operation,
|
Operation: operation,
|
||||||
RequestID: req.GetMeta().GetRequestId(),
|
RequestID: req.GetMeta().GetRequestId(),
|
||||||
SignatureValid: false,
|
ProviderRequestID: providerRequestID,
|
||||||
RequestHash: requestHash,
|
SignatureValid: signatureValid,
|
||||||
ResponseCode: statusCode,
|
RequestHash: requestHash,
|
||||||
ResponseBodyHash: responseHash,
|
ResponseCode: statusCode,
|
||||||
Status: logStatus,
|
ResponseBodyHash: responseHash,
|
||||||
CreatedAtMS: s.now().UnixMilli(),
|
Status: logStatus,
|
||||||
|
CreatedAtMS: s.now().UnixMilli(),
|
||||||
})
|
})
|
||||||
return responseBody, contentType, err
|
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")
|
return "", xerr.New(xerr.InvalidArgument, "platform launch url is invalid")
|
||||||
}
|
}
|
||||||
query := parsed.Query()
|
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_id", session.SessionID)
|
||||||
query.Set("session_token", token)
|
query.Set("session_token", token)
|
||||||
query.Set("game_id", session.GameID)
|
query.Set("game_id", session.GameID)
|
||||||
@ -530,6 +593,13 @@ func buildLaunchURL(game gamedomain.LaunchableGame, session gamedomain.LaunchSes
|
|||||||
return parsed.String(), nil
|
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) {
|
func jsonResponse(value any) ([]byte, string) {
|
||||||
body, err := json.Marshal(value)
|
body, err := json.Marshal(value)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@ -2,6 +2,11 @@ package game
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"crypto/aes"
|
||||||
|
"crypto/cipher"
|
||||||
|
"crypto/md5"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/hex"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"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) {
|
func TestHandleCallbackChangeCoinUsesWalletAndOrderIdempotency(t *testing.T) {
|
||||||
repo := &fakeRepository{
|
repo := &fakeRepository{
|
||||||
launchable: gamedomain.LaunchableGame{CatalogItem: gamedomain.CatalogItem{GameID: "demo_rocket_001", ProviderGameID: "rocket_001"}},
|
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 {
|
type fakeRepository struct {
|
||||||
launchable gamedomain.LaunchableGame
|
launchable gamedomain.LaunchableGame
|
||||||
|
platform gamedomain.Platform
|
||||||
session gamedomain.LaunchSession
|
session gamedomain.LaunchSession
|
||||||
order gamedomain.GameOrder
|
order gamedomain.GameOrder
|
||||||
|
repair gamedomain.RepairOrder
|
||||||
levelEvents []gamedomain.LevelEventOutbox
|
levelEvents []gamedomain.LevelEventOutbox
|
||||||
deliveredEvents []string
|
deliveredEvents []string
|
||||||
failedEvents []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) {
|
func (f *fakeRepository) GetLaunchableGame(context.Context, string, string) (gamedomain.LaunchableGame, error) {
|
||||||
return f.launchable, nil
|
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 {
|
func (f *fakeRepository) CreateLaunchSession(_ context.Context, session gamedomain.LaunchSession) error {
|
||||||
f.session = session
|
f.session = session
|
||||||
return nil
|
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) InsertCallbackLog(context.Context, gamedomain.CallbackLog) error { return nil }
|
||||||
func (f *fakeRepository) CreateGameOrder(_ context.Context, order gamedomain.GameOrder) (gamedomain.GameOrder, bool, error) {
|
func (f *fakeRepository) CreateGameOrder(_ context.Context, order gamedomain.GameOrder) (gamedomain.GameOrder, bool, error) {
|
||||||
if f.order.OrderID != "" {
|
if f.order.OrderID != "" {
|
||||||
@ -218,6 +513,13 @@ func (f *fakeRepository) MarkOrderFailed(_ context.Context, _ string, orderID st
|
|||||||
f.order.UpdatedAtMS = nowMs
|
f.order.UpdatedAtMS = nowMs
|
||||||
return nil
|
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) {
|
func (f *fakeRepository) ClaimPendingLevelEvents(context.Context, string, int64, time.Duration, int) ([]gamedomain.LevelEventOutbox, error) {
|
||||||
events := append([]gamedomain.LevelEventOutbox(nil), f.levelEvents...)
|
events := append([]gamedomain.LevelEventOutbox(nil), f.levelEvents...)
|
||||||
f.levelEvents = nil
|
f.levelEvents = nil
|
||||||
@ -290,3 +592,29 @@ func (f *fakeUser) GetUser(_ context.Context, req *userv1.GetUserRequest) (*user
|
|||||||
RegionId: 100,
|
RegionId: 100,
|
||||||
}}, nil
|
}}, 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[:])
|
||||||
|
}
|
||||||
|
|||||||
532
services/game-service/internal/service/game/yomi_adapter.go
Normal file
532
services/game-service/internal/service/game/yomi_adapter.go
Normal file
@ -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 ""
|
||||||
|
}
|
||||||
@ -2,7 +2,9 @@ package mysql
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"crypto/sha256"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
|
"encoding/hex"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"strings"
|
"strings"
|
||||||
@ -64,6 +66,7 @@ func (r *Repository) Migrate(ctx context.Context) error {
|
|||||||
platform_name VARCHAR(128) NOT NULL,
|
platform_name VARCHAR(128) NOT NULL,
|
||||||
status VARCHAR(32) NOT NULL,
|
status VARCHAR(32) NOT NULL,
|
||||||
api_base_url VARCHAR(512) NOT NULL DEFAULT '',
|
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_secret_ciphertext VARCHAR(1024) NOT NULL DEFAULT '',
|
||||||
callback_ip_whitelist JSON NULL,
|
callback_ip_whitelist JSON NULL,
|
||||||
adapter_config JSON NULL,
|
adapter_config JSON NULL,
|
||||||
@ -217,9 +220,29 @@ func (r *Repository) Migrate(ctx context.Context) error {
|
|||||||
return err
|
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)
|
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 {
|
func (r *Repository) seedDefaults(ctx context.Context) error {
|
||||||
if _, err := r.db.ExecContext(ctx,
|
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)
|
`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,
|
`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.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,
|
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
|
FROM game_catalog c
|
||||||
JOIN game_platforms p ON p.app_code = c.app_code AND p.platform_code = c.platform_code
|
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 = ?`,
|
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 item gamedomain.LaunchableGame
|
||||||
var tagsJSON string
|
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) {
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
return gamedomain.LaunchableGame{}, xerr.New(xerr.NotFound, "game not found")
|
return gamedomain.LaunchableGame{}, xerr.New(xerr.NotFound, "game not found")
|
||||||
}
|
}
|
||||||
return gamedomain.LaunchableGame{}, err
|
return gamedomain.LaunchableGame{}, err
|
||||||
}
|
}
|
||||||
_ = json.Unmarshal([]byte(tagsJSON), &item.Tags)
|
_ = 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
|
return item, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -324,6 +370,24 @@ func (r *Repository) CreateLaunchSession(ctx context.Context, session gamedomain
|
|||||||
return err
|
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 {
|
func (r *Repository) InsertCallbackLog(ctx context.Context, log gamedomain.CallbackLog) error {
|
||||||
_, err := r.db.ExecContext(ctx,
|
_, err := r.db.ExecContext(ctx,
|
||||||
`INSERT INTO game_callback_logs (
|
`INSERT INTO game_callback_logs (
|
||||||
@ -442,6 +506,50 @@ func (r *Repository) MarkOrderFailed(ctx context.Context, appCode string, orderI
|
|||||||
return err
|
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) {
|
func queryOrderByIDForUpdate(ctx context.Context, tx *sql.Tx, appCode string, orderID string) (gamedomain.GameOrder, bool, error) {
|
||||||
row := tx.QueryRowContext(ctx,
|
row := tx.QueryRowContext(ctx,
|
||||||
`SELECT app_code, order_id, platform_code, provider_order_id, provider_round_id, game_id, provider_game_id,
|
`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) {
|
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 = ?`
|
FROM game_platforms WHERE app_code = ?`
|
||||||
args := []any{appcode.Normalize(appCode)}
|
args := []any{appcode.Normalize(appCode)}
|
||||||
if strings.TrimSpace(status) != "" {
|
if strings.TrimSpace(status) != "" {
|
||||||
@ -570,8 +680,8 @@ func (r *Repository) ListPlatforms(ctx context.Context, appCode string, status s
|
|||||||
defer rows.Close()
|
defer rows.Close()
|
||||||
var items []gamedomain.Platform
|
var items []gamedomain.Platform
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var item gamedomain.Platform
|
item, err := scanPlatform(rows)
|
||||||
if err := rows.Scan(&item.AppCode, &item.PlatformCode, &item.PlatformName, &item.Status, &item.APIBaseURL, &item.SortOrder, &item.CreatedAtMS, &item.UpdatedAtMS); err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
items = append(items, item)
|
items = append(items, item)
|
||||||
@ -586,14 +696,29 @@ func (r *Repository) UpsertPlatform(ctx context.Context, platform gamedomain.Pla
|
|||||||
platform.CreatedAtMS = nowMs
|
platform.CreatedAtMS = nowMs
|
||||||
}
|
}
|
||||||
platform.UpdatedAtMS = nowMs
|
platform.UpdatedAtMS = nowMs
|
||||||
|
whitelistJSON, _ := json.Marshal(platform.CallbackIPWhitelist)
|
||||||
|
adapterConfig := strings.TrimSpace(platform.AdapterConfigJSON)
|
||||||
|
if adapterConfig == "" {
|
||||||
|
adapterConfig = "{}"
|
||||||
|
}
|
||||||
_, err := r.db.ExecContext(ctx,
|
_, 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)
|
`INSERT INTO game_platforms (
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
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),
|
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)`,
|
api_base_url = VALUES(api_base_url), adapter_type = VALUES(adapter_type),
|
||||||
platform.AppCode, platform.PlatformCode, platform.PlatformName, platform.Status, platform.APIBaseURL, platform.SortOrder, platform.CreatedAtMS, platform.UpdatedAtMS,
|
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) {
|
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
|
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 {
|
func levelEventOutboxSelectSQL() string {
|
||||||
return `SELECT app_code, event_id, order_id, user_id, game_id, provider_order_id,
|
return `SELECT app_code, event_id, order_id, user_id, game_id, provider_order_id,
|
||||||
provider_round_id, coin_amount, wallet_transaction_id,
|
provider_round_id, coin_amount, wallet_transaction_id,
|
||||||
@ -733,3 +869,8 @@ func truncate(value string, max int) string {
|
|||||||
}
|
}
|
||||||
return value[:max]
|
return value[:max]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func stableHash(value string) string {
|
||||||
|
sum := sha256.Sum256([]byte(value))
|
||||||
|
return hex.EncodeToString(sum[:])
|
||||||
|
}
|
||||||
|
|||||||
@ -188,9 +188,15 @@ func (unavailableRepository) ListGames(context.Context, gameservice.ListGamesQue
|
|||||||
func (unavailableRepository) GetLaunchableGame(context.Context, string, string) (gamedomain.LaunchableGame, error) {
|
func (unavailableRepository) GetLaunchableGame(context.Context, string, string) (gamedomain.LaunchableGame, error) {
|
||||||
return gamedomain.LaunchableGame{}, xerr.New(xerr.Unavailable, "game repository is not configured")
|
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 {
|
func (unavailableRepository) CreateLaunchSession(context.Context, gamedomain.LaunchSession) error {
|
||||||
return xerr.New(xerr.Unavailable, "game repository is not configured")
|
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 {
|
func (unavailableRepository) InsertCallbackLog(context.Context, gamedomain.CallbackLog) error {
|
||||||
return xerr.New(xerr.Unavailable, "game repository is not configured")
|
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 {
|
func (unavailableRepository) MarkOrderFailed(context.Context, string, string, string, string, string, int64) error {
|
||||||
return xerr.New(xerr.Unavailable, "game repository is not configured")
|
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) {
|
func (unavailableRepository) ClaimPendingLevelEvents(context.Context, string, int64, time.Duration, int) ([]gamedomain.LevelEventOutbox, error) {
|
||||||
return nil, xerr.New(xerr.Unavailable, "game repository is not configured")
|
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 {
|
func platformToProto(item gamedomain.Platform) *gamev1.GamePlatform {
|
||||||
return &gamev1.GamePlatform{
|
return &gamev1.GamePlatform{
|
||||||
AppCode: item.AppCode,
|
AppCode: item.AppCode,
|
||||||
PlatformCode: item.PlatformCode,
|
PlatformCode: item.PlatformCode,
|
||||||
PlatformName: item.PlatformName,
|
PlatformName: item.PlatformName,
|
||||||
Status: item.Status,
|
Status: item.Status,
|
||||||
ApiBaseUrl: item.APIBaseURL,
|
ApiBaseUrl: item.APIBaseURL,
|
||||||
SortOrder: item.SortOrder,
|
SortOrder: item.SortOrder,
|
||||||
CreatedAtMs: item.CreatedAtMS,
|
CreatedAtMs: item.CreatedAtMS,
|
||||||
UpdatedAtMs: item.UpdatedAtMS,
|
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{}
|
||||||
}
|
}
|
||||||
return gamedomain.Platform{
|
return gamedomain.Platform{
|
||||||
AppCode: item.GetAppCode(),
|
AppCode: item.GetAppCode(),
|
||||||
PlatformCode: item.GetPlatformCode(),
|
PlatformCode: item.GetPlatformCode(),
|
||||||
PlatformName: item.GetPlatformName(),
|
PlatformName: item.GetPlatformName(),
|
||||||
Status: item.GetStatus(),
|
Status: item.GetStatus(),
|
||||||
APIBaseURL: item.GetApiBaseUrl(),
|
APIBaseURL: item.GetApiBaseUrl(),
|
||||||
SortOrder: item.GetSortOrder(),
|
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.PlatformName = strings.TrimSpace(item.PlatformName)
|
||||||
item.Status = normalizeStatus(item.Status)
|
item.Status = normalizeStatus(item.Status)
|
||||||
item.APIBaseURL = strings.TrimSpace(item.APIBaseURL)
|
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 == "" {
|
if item.PlatformCode == "" || item.PlatformName == "" || item.Status == "" {
|
||||||
return gamedomain.Platform{}, xerr.New(xerr.InvalidArgument, "platform is incomplete")
|
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
|
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 {
|
func durationFromMillis(value int64) time.Duration {
|
||||||
if value <= 0 {
|
if value <= 0 {
|
||||||
return 0
|
return 0
|
||||||
|
|||||||
@ -1080,6 +1080,7 @@ func (h *Handler) sendGift(writer http.ResponseWriter, request *http.Request) {
|
|||||||
TargetUserIDs []int64 `json:"target_user_ids"`
|
TargetUserIDs []int64 `json:"target_user_ids"`
|
||||||
GiftID string `json:"gift_id"`
|
GiftID string `json:"gift_id"`
|
||||||
GiftCount int32 `json:"gift_count"`
|
GiftCount int32 `json:"gift_count"`
|
||||||
|
PoolID string `json:"pool_id"`
|
||||||
}
|
}
|
||||||
|
|
||||||
if !httpkit.Decode(writer, request, &body) {
|
if !httpkit.Decode(writer, request, &body) {
|
||||||
@ -1102,6 +1103,7 @@ func (h *Handler) sendGift(writer http.ResponseWriter, request *http.Request) {
|
|||||||
TargetUserId: firstUserID(targetUserIDs),
|
TargetUserId: firstUserID(targetUserIDs),
|
||||||
GiftId: body.GiftID,
|
GiftId: body.GiftID,
|
||||||
GiftCount: body.GiftCount,
|
GiftCount: body.GiftCount,
|
||||||
|
PoolId: body.PoolID,
|
||||||
})
|
})
|
||||||
httpkit.Write(writer, request, resp, err)
|
httpkit.Write(writer, request, resp, err)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -326,6 +326,8 @@ type SendGift struct {
|
|||||||
TargetUserID int64 `json:"target_user_id"`
|
TargetUserID int64 `json:"target_user_id"`
|
||||||
// GiftID 是礼物配置标识,具体配置有效性当前由调用方和 wallet 链路保证。
|
// GiftID 是礼物配置标识,具体配置有效性当前由调用方和 wallet 链路保证。
|
||||||
GiftID string `json:"gift_id"`
|
GiftID string `json:"gift_id"`
|
||||||
|
// PoolID 是幸运礼物奖池标识;为空表示普通礼物或默认幸运奖池。
|
||||||
|
PoolID string `json:"pool_id,omitempty"`
|
||||||
// GiftCount 是礼物数量,必须为正数。
|
// GiftCount 是礼物数量,必须为正数。
|
||||||
GiftCount int32 `json:"gift_count"`
|
GiftCount int32 `json:"gift_count"`
|
||||||
// BillingReceiptID 是 wallet-service 成功落账后的回执,用于排障和恢复审计。
|
// BillingReceiptID 是 wallet-service 成功落账后的回执,用于排障和恢复审计。
|
||||||
|
|||||||
@ -27,6 +27,7 @@ func (s *Service) SendGift(ctx context.Context, req *roomv1.SendGiftRequest) (*r
|
|||||||
TargetType: normalizeGiftTargetType(req.GetTargetType()),
|
TargetType: normalizeGiftTargetType(req.GetTargetType()),
|
||||||
TargetUserIDs: normalizeGiftTargetUserIDs(req.GetTargetUserId(), req.GetTargetUserIds()),
|
TargetUserIDs: normalizeGiftTargetUserIDs(req.GetTargetUserId(), req.GetTargetUserIds()),
|
||||||
GiftID: req.GetGiftId(),
|
GiftID: req.GetGiftId(),
|
||||||
|
PoolID: strings.TrimSpace(req.GetPoolId()),
|
||||||
GiftCount: req.GetGiftCount(),
|
GiftCount: req.GetGiftCount(),
|
||||||
}
|
}
|
||||||
if cmd.TargetType == "" {
|
if cmd.TargetType == "" {
|
||||||
@ -116,6 +117,7 @@ func (s *Service) SendGift(ctx context.Context, req *roomv1.SendGiftRequest) (*r
|
|||||||
SenderUserId: cmd.ActorUserID(),
|
SenderUserId: cmd.ActorUserID(),
|
||||||
TargetUserId: cmd.TargetUserID,
|
TargetUserId: cmd.TargetUserID,
|
||||||
GiftId: cmd.GiftID,
|
GiftId: cmd.GiftID,
|
||||||
|
PoolId: cmd.PoolID,
|
||||||
GiftCount: cmd.GiftCount,
|
GiftCount: cmd.GiftCount,
|
||||||
GiftValue: heatValue,
|
GiftValue: heatValue,
|
||||||
BillingReceiptId: billing.GetBillingReceiptId(),
|
BillingReceiptId: billing.GetBillingReceiptId(),
|
||||||
@ -179,6 +181,7 @@ func (s *Service) SendGift(ctx context.Context, req *roomv1.SendGiftRequest) (*r
|
|||||||
RoomVersion: current.Version,
|
RoomVersion: current.Version,
|
||||||
Attributes: map[string]string{
|
Attributes: map[string]string{
|
||||||
"gift_id": cmd.GiftID,
|
"gift_id": cmd.GiftID,
|
||||||
|
"pool_id": cmd.PoolID,
|
||||||
"gift_count": fmt.Sprintf("%d", cmd.GiftCount),
|
"gift_count": fmt.Sprintf("%d", cmd.GiftCount),
|
||||||
"billing_receipt_id": billing.GetBillingReceiptId(),
|
"billing_receipt_id": billing.GetBillingReceiptId(),
|
||||||
"coin_spent": fmt.Sprintf("%d", settledCommand.CoinSpent),
|
"coin_spent": fmt.Sprintf("%d", settledCommand.CoinSpent),
|
||||||
|
|||||||
@ -207,7 +207,8 @@ func TestRoomTreasureOpenDueEarlyMessageDoesNotPoisonCommandID(t *testing.T) {
|
|||||||
t.Fatalf("due delayed message should open treasure after retry: %v", err)
|
t.Fatalf("due delayed message should open treasure after retry: %v", err)
|
||||||
}
|
}
|
||||||
info, err := svc.GetRoomTreasure(ctx, &roomv1.GetRoomTreasureRequest{
|
info, err := svc.GetRoomTreasure(ctx, &roomv1.GetRoomTreasureRequest{
|
||||||
RoomId: roomID,
|
RoomId: roomID,
|
||||||
|
ViewerUserId: ownerID,
|
||||||
Meta: &roomv1.RequestMeta{
|
Meta: &roomv1.RequestMeta{
|
||||||
AppCode: appcode.Default,
|
AppCode: appcode.Default,
|
||||||
RequestId: "req-check",
|
RequestId: "req-check",
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user