游戏部署

This commit is contained in:
zhx 2026-05-20 18:07:33 +08:00
parent 36da9df0c7
commit 61f4e31b6a
45 changed files with 2840 additions and 327 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -465,6 +465,7 @@ message SendGiftRequest {
// target_type user all_micall_roomcouple HTTP // target_type user all_micall_roomcouple 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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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 服务监听器 |

View 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` 重新同步。

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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 '适配器配置',

View File

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

View 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"
}
}

View File

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

View File

@ -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[:])
}

View 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 ""
}

View File

@ -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[:])
}

View File

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

View File

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

View File

@ -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 成功落账后的回执,用于排障和恢复审计。

View File

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

View File

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