礼物性能优化

This commit is contained in:
zhx 2026-07-12 00:47:20 +08:00
parent f19f212e54
commit 7d011ce3d9
141 changed files with 19615 additions and 11113 deletions

View File

@ -728,29 +728,30 @@ type LuckyGiftDrawResult struct {
sizeCache protoimpl.SizeCache
unknownFields protoimpl.UnknownFields
DrawId string `protobuf:"bytes,1,opt,name=draw_id,json=drawId,proto3" json:"draw_id,omitempty"`
CommandId string `protobuf:"bytes,2,opt,name=command_id,json=commandId,proto3" json:"command_id,omitempty"`
GiftId string `protobuf:"bytes,3,opt,name=gift_id,json=giftId,proto3" json:"gift_id,omitempty"`
RuleVersion int64 `protobuf:"varint,4,opt,name=rule_version,json=ruleVersion,proto3" json:"rule_version,omitempty"`
ExperiencePool string `protobuf:"bytes,5,opt,name=experience_pool,json=experiencePool,proto3" json:"experience_pool,omitempty"`
SelectedTierId string `protobuf:"bytes,6,opt,name=selected_tier_id,json=selectedTierId,proto3" json:"selected_tier_id,omitempty"`
BaseRewardCoins int64 `protobuf:"varint,7,opt,name=base_reward_coins,json=baseRewardCoins,proto3" json:"base_reward_coins,omitempty"`
EffectiveRewardCoins int64 `protobuf:"varint,8,opt,name=effective_reward_coins,json=effectiveRewardCoins,proto3" json:"effective_reward_coins,omitempty"`
RewardStatus string `protobuf:"bytes,9,opt,name=reward_status,json=rewardStatus,proto3" json:"reward_status,omitempty"`
RtpWindowIndex int64 `protobuf:"varint,10,opt,name=rtp_window_index,json=rtpWindowIndex,proto3" json:"rtp_window_index,omitempty"`
GiftRtpWindowIndex int64 `protobuf:"varint,11,opt,name=gift_rtp_window_index,json=giftRtpWindowIndex,proto3" json:"gift_rtp_window_index,omitempty"`
GlobalBaseRtpPpm int64 `protobuf:"varint,12,opt,name=global_base_rtp_ppm,json=globalBaseRtpPpm,proto3" json:"global_base_rtp_ppm,omitempty"`
GiftBaseRtpPpm int64 `protobuf:"varint,13,opt,name=gift_base_rtp_ppm,json=giftBaseRtpPpm,proto3" json:"gift_base_rtp_ppm,omitempty"`
StageFeedback bool `protobuf:"varint,14,opt,name=stage_feedback,json=stageFeedback,proto3" json:"stage_feedback,omitempty"`
HighMultiplier bool `protobuf:"varint,15,opt,name=high_multiplier,json=highMultiplier,proto3" json:"high_multiplier,omitempty"`
CreatedAtMs int64 `protobuf:"varint,16,opt,name=created_at_ms,json=createdAtMs,proto3" json:"created_at_ms,omitempty"`
PoolId string `protobuf:"bytes,17,opt,name=pool_id,json=poolId,proto3" json:"pool_id,omitempty"`
MultiplierPpm int64 `protobuf:"varint,18,opt,name=multiplier_ppm,json=multiplierPpm,proto3" json:"multiplier_ppm,omitempty"`
WalletTransactionId string `protobuf:"bytes,19,opt,name=wallet_transaction_id,json=walletTransactionId,proto3" json:"wallet_transaction_id,omitempty"`
CoinBalanceAfter int64 `protobuf:"varint,20,opt,name=coin_balance_after,json=coinBalanceAfter,proto3" json:"coin_balance_after,omitempty"`
UserId int64 `protobuf:"varint,21,opt,name=user_id,json=userId,proto3" json:"user_id,omitempty"`
ExternalUserId string `protobuf:"bytes,22,opt,name=external_user_id,json=externalUserId,proto3" json:"external_user_id,omitempty"`
AppCode string `protobuf:"bytes,23,opt,name=app_code,json=appCode,proto3" json:"app_code,omitempty"`
DrawId string `protobuf:"bytes,1,opt,name=draw_id,json=drawId,proto3" json:"draw_id,omitempty"`
CommandId string `protobuf:"bytes,2,opt,name=command_id,json=commandId,proto3" json:"command_id,omitempty"`
GiftId string `protobuf:"bytes,3,opt,name=gift_id,json=giftId,proto3" json:"gift_id,omitempty"`
RuleVersion int64 `protobuf:"varint,4,opt,name=rule_version,json=ruleVersion,proto3" json:"rule_version,omitempty"`
ExperiencePool string `protobuf:"bytes,5,opt,name=experience_pool,json=experiencePool,proto3" json:"experience_pool,omitempty"`
SelectedTierId string `protobuf:"bytes,6,opt,name=selected_tier_id,json=selectedTierId,proto3" json:"selected_tier_id,omitempty"`
BaseRewardCoins int64 `protobuf:"varint,7,opt,name=base_reward_coins,json=baseRewardCoins,proto3" json:"base_reward_coins,omitempty"`
EffectiveRewardCoins int64 `protobuf:"varint,8,opt,name=effective_reward_coins,json=effectiveRewardCoins,proto3" json:"effective_reward_coins,omitempty"`
RewardStatus string `protobuf:"bytes,9,opt,name=reward_status,json=rewardStatus,proto3" json:"reward_status,omitempty"`
RtpWindowIndex int64 `protobuf:"varint,10,opt,name=rtp_window_index,json=rtpWindowIndex,proto3" json:"rtp_window_index,omitempty"`
GiftRtpWindowIndex int64 `protobuf:"varint,11,opt,name=gift_rtp_window_index,json=giftRtpWindowIndex,proto3" json:"gift_rtp_window_index,omitempty"`
GlobalBaseRtpPpm int64 `protobuf:"varint,12,opt,name=global_base_rtp_ppm,json=globalBaseRtpPpm,proto3" json:"global_base_rtp_ppm,omitempty"`
GiftBaseRtpPpm int64 `protobuf:"varint,13,opt,name=gift_base_rtp_ppm,json=giftBaseRtpPpm,proto3" json:"gift_base_rtp_ppm,omitempty"`
StageFeedback bool `protobuf:"varint,14,opt,name=stage_feedback,json=stageFeedback,proto3" json:"stage_feedback,omitempty"`
HighMultiplier bool `protobuf:"varint,15,opt,name=high_multiplier,json=highMultiplier,proto3" json:"high_multiplier,omitempty"`
CreatedAtMs int64 `protobuf:"varint,16,opt,name=created_at_ms,json=createdAtMs,proto3" json:"created_at_ms,omitempty"`
PoolId string `protobuf:"bytes,17,opt,name=pool_id,json=poolId,proto3" json:"pool_id,omitempty"`
MultiplierPpm int64 `protobuf:"varint,18,opt,name=multiplier_ppm,json=multiplierPpm,proto3" json:"multiplier_ppm,omitempty"`
WalletTransactionId string `protobuf:"bytes,19,opt,name=wallet_transaction_id,json=walletTransactionId,proto3" json:"wallet_transaction_id,omitempty"`
CoinBalanceAfter int64 `protobuf:"varint,20,opt,name=coin_balance_after,json=coinBalanceAfter,proto3" json:"coin_balance_after,omitempty"`
UserId int64 `protobuf:"varint,21,opt,name=user_id,json=userId,proto3" json:"user_id,omitempty"`
ExternalUserId string `protobuf:"bytes,22,opt,name=external_user_id,json=externalUserId,proto3" json:"external_user_id,omitempty"`
AppCode string `protobuf:"bytes,23,opt,name=app_code,json=appCode,proto3" json:"app_code,omitempty"`
Hits []*LuckyGiftHit `protobuf:"bytes,24,rep,name=hits,proto3" json:"hits,omitempty"`
}
func (x *LuckyGiftDrawResult) Reset() {
@ -944,6 +945,13 @@ func (x *LuckyGiftDrawResult) GetAppCode() string {
return ""
}
func (x *LuckyGiftDrawResult) GetHits() []*LuckyGiftHit {
if x != nil {
return x.Hits
}
return nil
}
type ExecuteLuckyGiftDrawRequest struct {
state protoimpl.MessageState
sizeCache protoimpl.SizeCache
@ -2326,6 +2334,85 @@ func (x *ListLuckyGiftPoolBalancesResponse) GetPools() []*LuckyGiftPoolBalance {
return nil
}
// LuckyGiftHit 只返回批量逐份开奖中实际中奖的精简位置,不复制全部审计明细。
// 声明追加在文件尾以保持既有 protobuf message index 稳定。
type LuckyGiftHit struct {
state protoimpl.MessageState
sizeCache protoimpl.SizeCache
unknownFields protoimpl.UnknownFields
GiftIndex int32 `protobuf:"varint,1,opt,name=gift_index,json=giftIndex,proto3" json:"gift_index,omitempty"`
DrawId string `protobuf:"bytes,2,opt,name=draw_id,json=drawId,proto3" json:"draw_id,omitempty"`
SelectedTierId string `protobuf:"bytes,3,opt,name=selected_tier_id,json=selectedTierId,proto3" json:"selected_tier_id,omitempty"`
MultiplierPpm int64 `protobuf:"varint,4,opt,name=multiplier_ppm,json=multiplierPpm,proto3" json:"multiplier_ppm,omitempty"`
RewardCoins int64 `protobuf:"varint,5,opt,name=reward_coins,json=rewardCoins,proto3" json:"reward_coins,omitempty"`
}
func (x *LuckyGiftHit) Reset() {
*x = LuckyGiftHit{}
mi := &file_proto_luckygift_v1_luckygift_proto_msgTypes[28]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *LuckyGiftHit) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*LuckyGiftHit) ProtoMessage() {}
func (x *LuckyGiftHit) ProtoReflect() protoreflect.Message {
mi := &file_proto_luckygift_v1_luckygift_proto_msgTypes[28]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use LuckyGiftHit.ProtoReflect.Descriptor instead.
func (*LuckyGiftHit) Descriptor() ([]byte, []int) {
return file_proto_luckygift_v1_luckygift_proto_rawDescGZIP(), []int{28}
}
func (x *LuckyGiftHit) GetGiftIndex() int32 {
if x != nil {
return x.GiftIndex
}
return 0
}
func (x *LuckyGiftHit) GetDrawId() string {
if x != nil {
return x.DrawId
}
return ""
}
func (x *LuckyGiftHit) GetSelectedTierId() string {
if x != nil {
return x.SelectedTierId
}
return ""
}
func (x *LuckyGiftHit) GetMultiplierPpm() int64 {
if x != nil {
return x.MultiplierPpm
}
return 0
}
func (x *LuckyGiftHit) GetRewardCoins() int64 {
if x != nil {
return x.RewardCoins
}
return 0
}
var File_proto_luckygift_v1_luckygift_proto protoreflect.FileDescriptor
var file_proto_luckygift_v1_luckygift_proto_rawDesc = []byte{
@ -2462,7 +2549,7 @@ var file_proto_luckygift_v1_luckygift_proto_rawDesc = []byte{
0x6e, 0x63, 0x65, 0x5f, 0x70, 0x6f, 0x6f, 0x6c, 0x18, 0x07, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0e,
0x65, 0x78, 0x70, 0x65, 0x72, 0x69, 0x65, 0x6e, 0x63, 0x65, 0x50, 0x6f, 0x6f, 0x6c, 0x12, 0x17,
0x0a, 0x07, 0x70, 0x6f, 0x6f, 0x6c, 0x5f, 0x69, 0x64, 0x18, 0x08, 0x20, 0x01, 0x28, 0x09, 0x52,
0x06, 0x70, 0x6f, 0x6f, 0x6c, 0x49, 0x64, 0x22, 0x8e, 0x07, 0x0a, 0x13, 0x4c, 0x75, 0x63, 0x6b,
0x06, 0x70, 0x6f, 0x6f, 0x6c, 0x49, 0x64, 0x22, 0xc4, 0x07, 0x0a, 0x13, 0x4c, 0x75, 0x63, 0x6b,
0x79, 0x47, 0x69, 0x66, 0x74, 0x44, 0x72, 0x61, 0x77, 0x52, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x12,
0x17, 0x0a, 0x07, 0x64, 0x72, 0x61, 0x77, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09,
0x52, 0x06, 0x64, 0x72, 0x61, 0x77, 0x49, 0x64, 0x12, 0x1d, 0x0a, 0x0a, 0x63, 0x6f, 0x6d, 0x6d,
@ -2519,324 +2606,339 @@ var file_proto_luckygift_v1_luckygift_proto_rawDesc = []byte{
0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x16, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0e, 0x65,
0x78, 0x74, 0x65, 0x72, 0x6e, 0x61, 0x6c, 0x55, 0x73, 0x65, 0x72, 0x49, 0x64, 0x12, 0x19, 0x0a,
0x08, 0x61, 0x70, 0x70, 0x5f, 0x63, 0x6f, 0x64, 0x65, 0x18, 0x17, 0x20, 0x01, 0x28, 0x09, 0x52,
0x07, 0x61, 0x70, 0x70, 0x43, 0x6f, 0x64, 0x65, 0x22, 0x5f, 0x0a, 0x1b, 0x45, 0x78, 0x65, 0x63,
0x75, 0x74, 0x65, 0x4c, 0x75, 0x63, 0x6b, 0x79, 0x47, 0x69, 0x66, 0x74, 0x44, 0x72, 0x61, 0x77,
0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x40, 0x0a, 0x0a, 0x6c, 0x75, 0x63, 0x6b, 0x79,
0x5f, 0x67, 0x69, 0x66, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x21, 0x2e, 0x68, 0x79,
0x61, 0x70, 0x70, 0x2e, 0x6c, 0x75, 0x63, 0x6b, 0x79, 0x67, 0x69, 0x66, 0x74, 0x2e, 0x76, 0x31,
0x2e, 0x4c, 0x75, 0x63, 0x6b, 0x79, 0x47, 0x69, 0x66, 0x74, 0x4d, 0x65, 0x74, 0x61, 0x52, 0x09,
0x6c, 0x75, 0x63, 0x6b, 0x79, 0x47, 0x69, 0x66, 0x74, 0x22, 0x5f, 0x0a, 0x1c, 0x45, 0x78, 0x65,
0x63, 0x75, 0x74, 0x65, 0x4c, 0x75, 0x63, 0x6b, 0x79, 0x47, 0x69, 0x66, 0x74, 0x44, 0x72, 0x61,
0x77, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x3f, 0x0a, 0x06, 0x72, 0x65, 0x73,
0x75, 0x6c, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x27, 0x2e, 0x68, 0x79, 0x61, 0x70,
0x07, 0x61, 0x70, 0x70, 0x43, 0x6f, 0x64, 0x65, 0x12, 0x34, 0x0a, 0x04, 0x68, 0x69, 0x74, 0x73,
0x18, 0x18, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x20, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x6c,
0x75, 0x63, 0x6b, 0x79, 0x67, 0x69, 0x66, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x4c, 0x75, 0x63, 0x6b,
0x79, 0x47, 0x69, 0x66, 0x74, 0x48, 0x69, 0x74, 0x52, 0x04, 0x68, 0x69, 0x74, 0x73, 0x22, 0x5f,
0x0a, 0x1b, 0x45, 0x78, 0x65, 0x63, 0x75, 0x74, 0x65, 0x4c, 0x75, 0x63, 0x6b, 0x79, 0x47, 0x69,
0x66, 0x74, 0x44, 0x72, 0x61, 0x77, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x40, 0x0a,
0x0a, 0x6c, 0x75, 0x63, 0x6b, 0x79, 0x5f, 0x67, 0x69, 0x66, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28,
0x0b, 0x32, 0x21, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x6c, 0x75, 0x63, 0x6b, 0x79, 0x67,
0x69, 0x66, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x4c, 0x75, 0x63, 0x6b, 0x79, 0x47, 0x69, 0x66, 0x74,
0x4d, 0x65, 0x74, 0x61, 0x52, 0x09, 0x6c, 0x75, 0x63, 0x6b, 0x79, 0x47, 0x69, 0x66, 0x74, 0x22,
0x5f, 0x0a, 0x1c, 0x45, 0x78, 0x65, 0x63, 0x75, 0x74, 0x65, 0x4c, 0x75, 0x63, 0x6b, 0x79, 0x47,
0x69, 0x66, 0x74, 0x44, 0x72, 0x61, 0x77, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12,
0x3f, 0x0a, 0x06, 0x72, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32,
0x27, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x6c, 0x75, 0x63, 0x6b, 0x79, 0x67, 0x69, 0x66,
0x74, 0x2e, 0x76, 0x31, 0x2e, 0x4c, 0x75, 0x63, 0x6b, 0x79, 0x47, 0x69, 0x66, 0x74, 0x44, 0x72,
0x61, 0x77, 0x52, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x52, 0x06, 0x72, 0x65, 0x73, 0x75, 0x6c, 0x74,
0x22, 0x66, 0x0a, 0x20, 0x42, 0x61, 0x74, 0x63, 0x68, 0x45, 0x78, 0x65, 0x63, 0x75, 0x74, 0x65,
0x4c, 0x75, 0x63, 0x6b, 0x79, 0x47, 0x69, 0x66, 0x74, 0x44, 0x72, 0x61, 0x77, 0x52, 0x65, 0x71,
0x75, 0x65, 0x73, 0x74, 0x12, 0x42, 0x0a, 0x0b, 0x6c, 0x75, 0x63, 0x6b, 0x79, 0x5f, 0x67, 0x69,
0x66, 0x74, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x21, 0x2e, 0x68, 0x79, 0x61, 0x70,
0x70, 0x2e, 0x6c, 0x75, 0x63, 0x6b, 0x79, 0x67, 0x69, 0x66, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x4c,
0x75, 0x63, 0x6b, 0x79, 0x47, 0x69, 0x66, 0x74, 0x44, 0x72, 0x61, 0x77, 0x52, 0x65, 0x73, 0x75,
0x6c, 0x74, 0x52, 0x06, 0x72, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x22, 0x66, 0x0a, 0x20, 0x42, 0x61,
0x74, 0x63, 0x68, 0x45, 0x78, 0x65, 0x63, 0x75, 0x74, 0x65, 0x4c, 0x75, 0x63, 0x6b, 0x79, 0x47,
0x69, 0x66, 0x74, 0x44, 0x72, 0x61, 0x77, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x42,
0x0a, 0x0b, 0x6c, 0x75, 0x63, 0x6b, 0x79, 0x5f, 0x67, 0x69, 0x66, 0x74, 0x73, 0x18, 0x01, 0x20,
0x03, 0x28, 0x0b, 0x32, 0x21, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x6c, 0x75, 0x63, 0x6b,
0x79, 0x67, 0x69, 0x66, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x4c, 0x75, 0x63, 0x6b, 0x79, 0x47, 0x69,
0x66, 0x74, 0x4d, 0x65, 0x74, 0x61, 0x52, 0x0a, 0x6c, 0x75, 0x63, 0x6b, 0x79, 0x47, 0x69, 0x66,
0x74, 0x73, 0x22, 0x66, 0x0a, 0x21, 0x42, 0x61, 0x74, 0x63, 0x68, 0x45, 0x78, 0x65, 0x63, 0x75,
0x74, 0x65, 0x4c, 0x75, 0x63, 0x6b, 0x79, 0x47, 0x69, 0x66, 0x74, 0x44, 0x72, 0x61, 0x77, 0x52,
0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x41, 0x0a, 0x07, 0x72, 0x65, 0x73, 0x75, 0x6c,
0x74, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x27, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70,
0x2e, 0x6c, 0x75, 0x63, 0x6b, 0x79, 0x67, 0x69, 0x66, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x4c, 0x75,
0x63, 0x6b, 0x79, 0x47, 0x69, 0x66, 0x74, 0x44, 0x72, 0x61, 0x77, 0x52, 0x65, 0x73, 0x75, 0x6c,
0x74, 0x52, 0x07, 0x72, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x73, 0x22, 0x8d, 0x03, 0x0a, 0x17, 0x45,
0x78, 0x74, 0x65, 0x72, 0x6e, 0x61, 0x6c, 0x47, 0x69, 0x66, 0x74, 0x44, 0x72, 0x61, 0x77, 0x52,
0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x33, 0x0a, 0x04, 0x6d, 0x65, 0x74, 0x61, 0x18, 0x01,
0x20, 0x01, 0x28, 0x0b, 0x32, 0x1f, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x6c, 0x75, 0x63,
0x6b, 0x79, 0x67, 0x69, 0x66, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73,
0x74, 0x4d, 0x65, 0x74, 0x61, 0x52, 0x04, 0x6d, 0x65, 0x74, 0x61, 0x12, 0x19, 0x0a, 0x08, 0x61,
0x70, 0x70, 0x5f, 0x63, 0x6f, 0x64, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x61,
0x70, 0x70, 0x43, 0x6f, 0x64, 0x65, 0x12, 0x28, 0x0a, 0x10, 0x65, 0x78, 0x74, 0x65, 0x72, 0x6e,
0x61, 0x6c, 0x5f, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09,
0x52, 0x0e, 0x65, 0x78, 0x74, 0x65, 0x72, 0x6e, 0x61, 0x6c, 0x55, 0x73, 0x65, 0x72, 0x49, 0x64,
0x12, 0x1d, 0x0a, 0x0a, 0x72, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x5f, 0x69, 0x64, 0x18, 0x05,
0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x72, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x49, 0x64, 0x12,
0x1d, 0x0a, 0x0a, 0x67, 0x69, 0x66, 0x74, 0x5f, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x18, 0x06, 0x20,
0x01, 0x28, 0x03, 0x52, 0x09, 0x67, 0x69, 0x66, 0x74, 0x43, 0x6f, 0x75, 0x6e, 0x74, 0x12, 0x1f,
0x0a, 0x0b, 0x75, 0x6e, 0x69, 0x74, 0x5f, 0x61, 0x6d, 0x6f, 0x75, 0x6e, 0x74, 0x18, 0x07, 0x20,
0x01, 0x28, 0x03, 0x52, 0x0a, 0x75, 0x6e, 0x69, 0x74, 0x41, 0x6d, 0x6f, 0x75, 0x6e, 0x74, 0x12,
0x21, 0x0a, 0x0c, 0x74, 0x6f, 0x74, 0x61, 0x6c, 0x5f, 0x61, 0x6d, 0x6f, 0x75, 0x6e, 0x74, 0x18,
0x08, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0b, 0x74, 0x6f, 0x74, 0x61, 0x6c, 0x41, 0x6d, 0x6f, 0x75,
0x6e, 0x74, 0x12, 0x1a, 0x0a, 0x08, 0x63, 0x75, 0x72, 0x72, 0x65, 0x6e, 0x63, 0x79, 0x18, 0x09,
0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x63, 0x75, 0x72, 0x72, 0x65, 0x6e, 0x63, 0x79, 0x12, 0x1c,
0x0a, 0x0a, 0x70, 0x61, 0x69, 0x64, 0x5f, 0x61, 0x74, 0x5f, 0x6d, 0x73, 0x18, 0x0a, 0x20, 0x01,
0x28, 0x03, 0x52, 0x08, 0x70, 0x61, 0x69, 0x64, 0x41, 0x74, 0x4d, 0x73, 0x12, 0x23, 0x0a, 0x0d,
0x6d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x5f, 0x6a, 0x73, 0x6f, 0x6e, 0x18, 0x0b, 0x20,
0x01, 0x28, 0x09, 0x52, 0x0c, 0x6d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x4a, 0x73, 0x6f,
0x6e, 0x12, 0x17, 0x0a, 0x07, 0x70, 0x6f, 0x6f, 0x6c, 0x5f, 0x69, 0x64, 0x18, 0x0c, 0x20, 0x01,
0x28, 0x09, 0x52, 0x06, 0x70, 0x6f, 0x6f, 0x6c, 0x49, 0x64, 0x22, 0xb2, 0x03, 0x0a, 0x18, 0x45,
0x78, 0x74, 0x65, 0x72, 0x6e, 0x61, 0x6c, 0x47, 0x69, 0x66, 0x74, 0x44, 0x72, 0x61, 0x77, 0x52,
0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x17, 0x0a, 0x07, 0x64, 0x72, 0x61, 0x77, 0x5f,
0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x64, 0x72, 0x61, 0x77, 0x49, 0x64,
0x12, 0x1d, 0x0a, 0x0a, 0x72, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x5f, 0x69, 0x64, 0x18, 0x02,
0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x72, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x49, 0x64, 0x12,
0x19, 0x0a, 0x08, 0x61, 0x70, 0x70, 0x5f, 0x63, 0x6f, 0x64, 0x65, 0x18, 0x04, 0x20, 0x01, 0x28,
0x09, 0x52, 0x07, 0x61, 0x70, 0x70, 0x43, 0x6f, 0x64, 0x65, 0x12, 0x28, 0x0a, 0x10, 0x65, 0x78,
0x74, 0x65, 0x72, 0x6e, 0x61, 0x6c, 0x5f, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x05,
0x20, 0x01, 0x28, 0x09, 0x52, 0x0e, 0x65, 0x78, 0x74, 0x65, 0x72, 0x6e, 0x61, 0x6c, 0x55, 0x73,
0x65, 0x72, 0x49, 0x64, 0x12, 0x1d, 0x0a, 0x0a, 0x67, 0x69, 0x66, 0x74, 0x5f, 0x63, 0x6f, 0x75,
0x6e, 0x74, 0x18, 0x06, 0x20, 0x01, 0x28, 0x03, 0x52, 0x09, 0x67, 0x69, 0x66, 0x74, 0x43, 0x6f,
0x75, 0x6e, 0x74, 0x12, 0x1f, 0x0a, 0x0b, 0x75, 0x6e, 0x69, 0x74, 0x5f, 0x61, 0x6d, 0x6f, 0x75,
0x6e, 0x74, 0x18, 0x07, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0a, 0x75, 0x6e, 0x69, 0x74, 0x41, 0x6d,
0x6f, 0x75, 0x6e, 0x74, 0x12, 0x21, 0x0a, 0x0c, 0x74, 0x6f, 0x74, 0x61, 0x6c, 0x5f, 0x61, 0x6d,
0x6f, 0x75, 0x6e, 0x74, 0x18, 0x08, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0b, 0x74, 0x6f, 0x74, 0x61,
0x6c, 0x41, 0x6d, 0x6f, 0x75, 0x6e, 0x74, 0x12, 0x23, 0x0a, 0x0d, 0x72, 0x65, 0x77, 0x61, 0x72,
0x64, 0x5f, 0x61, 0x6d, 0x6f, 0x75, 0x6e, 0x74, 0x18, 0x09, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0c,
0x72, 0x65, 0x77, 0x61, 0x72, 0x64, 0x41, 0x6d, 0x6f, 0x75, 0x6e, 0x74, 0x12, 0x25, 0x0a, 0x0e,
0x6d, 0x75, 0x6c, 0x74, 0x69, 0x70, 0x6c, 0x69, 0x65, 0x72, 0x5f, 0x70, 0x70, 0x6d, 0x18, 0x0a,
0x20, 0x01, 0x28, 0x03, 0x52, 0x0d, 0x6d, 0x75, 0x6c, 0x74, 0x69, 0x70, 0x6c, 0x69, 0x65, 0x72,
0x50, 0x70, 0x6d, 0x12, 0x23, 0x0a, 0x0d, 0x72, 0x65, 0x77, 0x61, 0x72, 0x64, 0x5f, 0x73, 0x74,
0x61, 0x74, 0x75, 0x73, 0x18, 0x0b, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0c, 0x72, 0x65, 0x77, 0x61,
0x72, 0x64, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x12, 0x21, 0x0a, 0x0c, 0x72, 0x75, 0x6c, 0x65,
0x5f, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x0c, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0b,
0x72, 0x75, 0x6c, 0x65, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x22, 0x0a, 0x0d, 0x63,
0x72, 0x65, 0x61, 0x74, 0x65, 0x64, 0x5f, 0x61, 0x74, 0x5f, 0x6d, 0x73, 0x18, 0x0d, 0x20, 0x01,
0x28, 0x03, 0x52, 0x0b, 0x63, 0x72, 0x65, 0x61, 0x74, 0x65, 0x64, 0x41, 0x74, 0x4d, 0x73, 0x22,
0x69, 0x0a, 0x19, 0x47, 0x65, 0x74, 0x4c, 0x75, 0x63, 0x6b, 0x79, 0x47, 0x69, 0x66, 0x74, 0x43,
0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x33, 0x0a, 0x04,
0x75, 0x63, 0x6b, 0x79, 0x47, 0x69, 0x66, 0x74, 0x4d, 0x65, 0x74, 0x61, 0x52, 0x0a, 0x6c, 0x75,
0x63, 0x6b, 0x79, 0x47, 0x69, 0x66, 0x74, 0x73, 0x22, 0x66, 0x0a, 0x21, 0x42, 0x61, 0x74, 0x63,
0x68, 0x45, 0x78, 0x65, 0x63, 0x75, 0x74, 0x65, 0x4c, 0x75, 0x63, 0x6b, 0x79, 0x47, 0x69, 0x66,
0x74, 0x44, 0x72, 0x61, 0x77, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x41, 0x0a,
0x07, 0x72, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x27,
0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x6c, 0x75, 0x63, 0x6b, 0x79, 0x67, 0x69, 0x66, 0x74,
0x2e, 0x76, 0x31, 0x2e, 0x4c, 0x75, 0x63, 0x6b, 0x79, 0x47, 0x69, 0x66, 0x74, 0x44, 0x72, 0x61,
0x77, 0x52, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x52, 0x07, 0x72, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x73,
0x22, 0x8d, 0x03, 0x0a, 0x17, 0x45, 0x78, 0x74, 0x65, 0x72, 0x6e, 0x61, 0x6c, 0x47, 0x69, 0x66,
0x74, 0x44, 0x72, 0x61, 0x77, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x33, 0x0a, 0x04,
0x6d, 0x65, 0x74, 0x61, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1f, 0x2e, 0x68, 0x79, 0x61,
0x70, 0x70, 0x2e, 0x6c, 0x75, 0x63, 0x6b, 0x79, 0x67, 0x69, 0x66, 0x74, 0x2e, 0x76, 0x31, 0x2e,
0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x4d, 0x65, 0x74, 0x61, 0x52, 0x04, 0x6d, 0x65, 0x74,
0x61, 0x12, 0x17, 0x0a, 0x07, 0x70, 0x6f, 0x6f, 0x6c, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01,
0x28, 0x09, 0x52, 0x06, 0x70, 0x6f, 0x6f, 0x6c, 0x49, 0x64, 0x22, 0x5d, 0x0a, 0x1a, 0x47, 0x65,
0x74, 0x4c, 0x75, 0x63, 0x6b, 0x79, 0x47, 0x69, 0x66, 0x74, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67,
0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x3f, 0x0a, 0x06, 0x63, 0x6f, 0x6e, 0x66,
0x69, 0x67, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x27, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70,
0x2e, 0x6c, 0x75, 0x63, 0x6b, 0x79, 0x67, 0x69, 0x66, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x4c, 0x75,
0x63, 0x6b, 0x79, 0x47, 0x69, 0x66, 0x74, 0x52, 0x75, 0x6c, 0x65, 0x43, 0x6f, 0x6e, 0x66, 0x69,
0x67, 0x52, 0x06, 0x63, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x22, 0xc0, 0x01, 0x0a, 0x1c, 0x55, 0x70,
0x73, 0x65, 0x72, 0x74, 0x4c, 0x75, 0x63, 0x6b, 0x79, 0x47, 0x69, 0x66, 0x74, 0x43, 0x6f, 0x6e,
0x66, 0x69, 0x67, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x33, 0x0a, 0x04, 0x6d, 0x65,
0x74, 0x61, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1f, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70,
0x2e, 0x6c, 0x75, 0x63, 0x6b, 0x79, 0x67, 0x69, 0x66, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x52, 0x65,
0x71, 0x75, 0x65, 0x73, 0x74, 0x4d, 0x65, 0x74, 0x61, 0x52, 0x04, 0x6d, 0x65, 0x74, 0x61, 0x12,
0x3f, 0x0a, 0x06, 0x63, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32,
0x27, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x6c, 0x75, 0x63, 0x6b, 0x79, 0x67, 0x69, 0x66,
0x74, 0x2e, 0x76, 0x31, 0x2e, 0x4c, 0x75, 0x63, 0x6b, 0x79, 0x47, 0x69, 0x66, 0x74, 0x52, 0x75,
0x6c, 0x65, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, 0x06, 0x63, 0x6f, 0x6e, 0x66, 0x69, 0x67,
0x12, 0x2a, 0x0a, 0x11, 0x6f, 0x70, 0x65, 0x72, 0x61, 0x74, 0x6f, 0x72, 0x5f, 0x61, 0x64, 0x6d,
0x69, 0x6e, 0x5f, 0x69, 0x64, 0x18, 0x03, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0f, 0x6f, 0x70, 0x65,
0x72, 0x61, 0x74, 0x6f, 0x72, 0x41, 0x64, 0x6d, 0x69, 0x6e, 0x49, 0x64, 0x22, 0x60, 0x0a, 0x1d,
0x55, 0x70, 0x73, 0x65, 0x72, 0x74, 0x4c, 0x75, 0x63, 0x6b, 0x79, 0x47, 0x69, 0x66, 0x74, 0x43,
0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x3f, 0x0a,
0x06, 0x63, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x27, 0x2e,
0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x6c, 0x75, 0x63, 0x6b, 0x79, 0x67, 0x69, 0x66, 0x74, 0x2e,
0x76, 0x31, 0x2e, 0x4c, 0x75, 0x63, 0x6b, 0x79, 0x47, 0x69, 0x66, 0x74, 0x52, 0x75, 0x6c, 0x65,
0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, 0x06, 0x63, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x22, 0x52,
0x0a, 0x1b, 0x4c, 0x69, 0x73, 0x74, 0x4c, 0x75, 0x63, 0x6b, 0x79, 0x47, 0x69, 0x66, 0x74, 0x43,
0x6f, 0x6e, 0x66, 0x69, 0x67, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x33, 0x0a,
0x04, 0x6d, 0x65, 0x74, 0x61, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1f, 0x2e, 0x68, 0x79,
0x61, 0x70, 0x70, 0x2e, 0x6c, 0x75, 0x63, 0x6b, 0x79, 0x67, 0x69, 0x66, 0x74, 0x2e, 0x76, 0x31,
0x2e, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x4d, 0x65, 0x74, 0x61, 0x52, 0x04, 0x6d, 0x65,
0x74, 0x61, 0x22, 0x61, 0x0a, 0x1c, 0x4c, 0x69, 0x73, 0x74, 0x4c, 0x75, 0x63, 0x6b, 0x79, 0x47,
0x69, 0x66, 0x74, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e,
0x73, 0x65, 0x12, 0x41, 0x0a, 0x07, 0x63, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x73, 0x18, 0x01, 0x20,
0x03, 0x28, 0x0b, 0x32, 0x27, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x6c, 0x75, 0x63, 0x6b,
0x79, 0x67, 0x69, 0x66, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x4c, 0x75, 0x63, 0x6b, 0x79, 0x47, 0x69,
0x66, 0x74, 0x52, 0x75, 0x6c, 0x65, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, 0x07, 0x63, 0x6f,
0x6e, 0x66, 0x69, 0x67, 0x73, 0x22, 0xcc, 0x02, 0x0a, 0x19, 0x4c, 0x69, 0x73, 0x74, 0x4c, 0x75,
0x63, 0x6b, 0x79, 0x47, 0x69, 0x66, 0x74, 0x44, 0x72, 0x61, 0x77, 0x73, 0x52, 0x65, 0x71, 0x75,
0x65, 0x73, 0x74, 0x12, 0x33, 0x0a, 0x04, 0x6d, 0x65, 0x74, 0x61, 0x18, 0x01, 0x20, 0x01, 0x28,
0x0b, 0x32, 0x1f, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x6c, 0x75, 0x63, 0x6b, 0x79, 0x67,
0x69, 0x66, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x4d, 0x65,
0x74, 0x61, 0x52, 0x04, 0x6d, 0x65, 0x74, 0x61, 0x12, 0x17, 0x0a, 0x07, 0x67, 0x69, 0x66, 0x74,
0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x67, 0x69, 0x66, 0x74, 0x49,
0x64, 0x12, 0x17, 0x0a, 0x07, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x03, 0x20, 0x01,
0x28, 0x03, 0x52, 0x06, 0x75, 0x73, 0x65, 0x72, 0x49, 0x64, 0x12, 0x17, 0x0a, 0x07, 0x72, 0x6f,
0x6f, 0x6d, 0x5f, 0x69, 0x64, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x72, 0x6f, 0x6f,
0x6d, 0x49, 0x64, 0x12, 0x16, 0x0a, 0x06, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x18, 0x05, 0x20,
0x01, 0x28, 0x09, 0x52, 0x06, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x12, 0x12, 0x0a, 0x04, 0x70,
0x61, 0x67, 0x65, 0x18, 0x06, 0x20, 0x01, 0x28, 0x05, 0x52, 0x04, 0x70, 0x61, 0x67, 0x65, 0x12,
0x1b, 0x0a, 0x09, 0x70, 0x61, 0x67, 0x65, 0x5f, 0x73, 0x69, 0x7a, 0x65, 0x18, 0x07, 0x20, 0x01,
0x28, 0x05, 0x52, 0x08, 0x70, 0x61, 0x67, 0x65, 0x53, 0x69, 0x7a, 0x65, 0x12, 0x17, 0x0a, 0x07,
0x70, 0x6f, 0x6f, 0x6c, 0x5f, 0x69, 0x64, 0x18, 0x08, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x70,
0x6f, 0x6f, 0x6c, 0x49, 0x64, 0x12, 0x28, 0x0a, 0x10, 0x65, 0x78, 0x74, 0x65, 0x72, 0x6e, 0x61,
0x6c, 0x5f, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x0a, 0x20, 0x01, 0x28, 0x09, 0x52,
0x0e, 0x65, 0x78, 0x74, 0x65, 0x72, 0x6e, 0x61, 0x6c, 0x55, 0x73, 0x65, 0x72, 0x49, 0x64, 0x12,
0x23, 0x0a, 0x0d, 0x65, 0x78, 0x74, 0x65, 0x72, 0x6e, 0x61, 0x6c, 0x5f, 0x6f, 0x6e, 0x6c, 0x79,
0x18, 0x0b, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0c, 0x65, 0x78, 0x74, 0x65, 0x72, 0x6e, 0x61, 0x6c,
0x4f, 0x6e, 0x6c, 0x79, 0x22, 0x71, 0x0a, 0x1a, 0x4c, 0x69, 0x73, 0x74, 0x4c, 0x75, 0x63, 0x6b,
0x79, 0x47, 0x69, 0x66, 0x74, 0x44, 0x72, 0x61, 0x77, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e,
0x73, 0x65, 0x12, 0x3d, 0x0a, 0x05, 0x64, 0x72, 0x61, 0x77, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28,
0x0b, 0x32, 0x27, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x6c, 0x75, 0x63, 0x6b, 0x79, 0x67,
0x69, 0x66, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x4c, 0x75, 0x63, 0x6b, 0x79, 0x47, 0x69, 0x66, 0x74,
0x44, 0x72, 0x61, 0x77, 0x52, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x52, 0x05, 0x64, 0x72, 0x61, 0x77,
0x73, 0x12, 0x14, 0x0a, 0x05, 0x74, 0x6f, 0x74, 0x61, 0x6c, 0x18, 0x02, 0x20, 0x01, 0x28, 0x03,
0x52, 0x05, 0x74, 0x6f, 0x74, 0x61, 0x6c, 0x22, 0xaf, 0x03, 0x0a, 0x14, 0x4c, 0x75, 0x63, 0x6b,
0x79, 0x47, 0x69, 0x66, 0x74, 0x44, 0x72, 0x61, 0x77, 0x53, 0x75, 0x6d, 0x6d, 0x61, 0x72, 0x79,
0x12, 0x17, 0x0a, 0x07, 0x70, 0x6f, 0x6f, 0x6c, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28,
0x09, 0x52, 0x06, 0x70, 0x6f, 0x6f, 0x6c, 0x49, 0x64, 0x12, 0x1f, 0x0a, 0x0b, 0x74, 0x6f, 0x74,
0x61, 0x6c, 0x5f, 0x64, 0x72, 0x61, 0x77, 0x73, 0x18, 0x02, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0a,
0x74, 0x6f, 0x74, 0x61, 0x6c, 0x44, 0x72, 0x61, 0x77, 0x73, 0x12, 0x21, 0x0a, 0x0c, 0x75, 0x6e,
0x69, 0x71, 0x75, 0x65, 0x5f, 0x75, 0x73, 0x65, 0x72, 0x73, 0x18, 0x03, 0x20, 0x01, 0x28, 0x03,
0x52, 0x0b, 0x75, 0x6e, 0x69, 0x71, 0x75, 0x65, 0x55, 0x73, 0x65, 0x72, 0x73, 0x12, 0x21, 0x0a,
0x0c, 0x75, 0x6e, 0x69, 0x71, 0x75, 0x65, 0x5f, 0x72, 0x6f, 0x6f, 0x6d, 0x73, 0x18, 0x04, 0x20,
0x01, 0x28, 0x03, 0x52, 0x0b, 0x75, 0x6e, 0x69, 0x71, 0x75, 0x65, 0x52, 0x6f, 0x6f, 0x6d, 0x73,
0x12, 0x2a, 0x0a, 0x11, 0x74, 0x6f, 0x74, 0x61, 0x6c, 0x5f, 0x73, 0x70, 0x65, 0x6e, 0x74, 0x5f,
0x63, 0x6f, 0x69, 0x6e, 0x73, 0x18, 0x05, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0f, 0x74, 0x6f, 0x74,
0x61, 0x6c, 0x53, 0x70, 0x65, 0x6e, 0x74, 0x43, 0x6f, 0x69, 0x6e, 0x73, 0x12, 0x2c, 0x0a, 0x12,
0x74, 0x6f, 0x74, 0x61, 0x6c, 0x5f, 0x72, 0x65, 0x77, 0x61, 0x72, 0x64, 0x5f, 0x63, 0x6f, 0x69,
0x6e, 0x73, 0x18, 0x06, 0x20, 0x01, 0x28, 0x03, 0x52, 0x10, 0x74, 0x6f, 0x74, 0x61, 0x6c, 0x52,
0x65, 0x77, 0x61, 0x72, 0x64, 0x43, 0x6f, 0x69, 0x6e, 0x73, 0x12, 0x2a, 0x0a, 0x11, 0x62, 0x61,
0x73, 0x65, 0x5f, 0x72, 0x65, 0x77, 0x61, 0x72, 0x64, 0x5f, 0x63, 0x6f, 0x69, 0x6e, 0x73, 0x18,
0x07, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0f, 0x62, 0x61, 0x73, 0x65, 0x52, 0x65, 0x77, 0x61, 0x72,
0x64, 0x43, 0x6f, 0x69, 0x6e, 0x73, 0x12, 0x24, 0x0a, 0x0e, 0x61, 0x63, 0x74, 0x75, 0x61, 0x6c,
0x5f, 0x72, 0x74, 0x70, 0x5f, 0x70, 0x70, 0x6d, 0x18, 0x08, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0c,
0x61, 0x63, 0x74, 0x75, 0x61, 0x6c, 0x52, 0x74, 0x70, 0x50, 0x70, 0x6d, 0x12, 0x23, 0x0a, 0x0d,
0x70, 0x65, 0x6e, 0x64, 0x69, 0x6e, 0x67, 0x5f, 0x64, 0x72, 0x61, 0x77, 0x73, 0x18, 0x09, 0x20,
0x01, 0x28, 0x03, 0x52, 0x0c, 0x70, 0x65, 0x6e, 0x64, 0x69, 0x6e, 0x67, 0x44, 0x72, 0x61, 0x77,
0x73, 0x12, 0x23, 0x0a, 0x0d, 0x67, 0x72, 0x61, 0x6e, 0x74, 0x65, 0x64, 0x5f, 0x64, 0x72, 0x61,
0x77, 0x73, 0x18, 0x0a, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0c, 0x67, 0x72, 0x61, 0x6e, 0x74, 0x65,
0x64, 0x44, 0x72, 0x61, 0x77, 0x73, 0x12, 0x21, 0x0a, 0x0c, 0x66, 0x61, 0x69, 0x6c, 0x65, 0x64,
0x5f, 0x64, 0x72, 0x61, 0x77, 0x73, 0x18, 0x0b, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0b, 0x66, 0x61,
0x69, 0x6c, 0x65, 0x64, 0x44, 0x72, 0x61, 0x77, 0x73, 0x22, 0xb6, 0x02, 0x0a, 0x14, 0x4c, 0x75,
0x63, 0x6b, 0x79, 0x47, 0x69, 0x66, 0x74, 0x50, 0x6f, 0x6f, 0x6c, 0x42, 0x61, 0x6c, 0x61, 0x6e,
0x63, 0x65, 0x12, 0x19, 0x0a, 0x08, 0x61, 0x70, 0x70, 0x5f, 0x63, 0x6f, 0x64, 0x65, 0x18, 0x01,
0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x61, 0x70, 0x70, 0x43, 0x6f, 0x64, 0x65, 0x12, 0x17, 0x0a,
0x07, 0x70, 0x6f, 0x6f, 0x6c, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06,
0x70, 0x6f, 0x6f, 0x6c, 0x49, 0x64, 0x12, 0x18, 0x0a, 0x07, 0x62, 0x61, 0x6c, 0x61, 0x6e, 0x63,
0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x03, 0x52, 0x07, 0x62, 0x61, 0x6c, 0x61, 0x6e, 0x63, 0x65,
0x12, 0x23, 0x0a, 0x0d, 0x72, 0x65, 0x73, 0x65, 0x72, 0x76, 0x65, 0x5f, 0x66, 0x6c, 0x6f, 0x6f,
0x72, 0x18, 0x04, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0c, 0x72, 0x65, 0x73, 0x65, 0x72, 0x76, 0x65,
0x46, 0x6c, 0x6f, 0x6f, 0x72, 0x12, 0x2b, 0x0a, 0x11, 0x61, 0x76, 0x61, 0x69, 0x6c, 0x61, 0x62,
0x6c, 0x65, 0x5f, 0x62, 0x61, 0x6c, 0x61, 0x6e, 0x63, 0x65, 0x18, 0x05, 0x20, 0x01, 0x28, 0x03,
0x52, 0x10, 0x61, 0x76, 0x61, 0x69, 0x6c, 0x61, 0x62, 0x6c, 0x65, 0x42, 0x61, 0x6c, 0x61, 0x6e,
0x63, 0x65, 0x12, 0x19, 0x0a, 0x08, 0x74, 0x6f, 0x74, 0x61, 0x6c, 0x5f, 0x69, 0x6e, 0x18, 0x06,
0x20, 0x01, 0x28, 0x03, 0x52, 0x07, 0x74, 0x6f, 0x74, 0x61, 0x6c, 0x49, 0x6e, 0x12, 0x1b, 0x0a,
0x09, 0x74, 0x6f, 0x74, 0x61, 0x6c, 0x5f, 0x6f, 0x75, 0x74, 0x18, 0x07, 0x20, 0x01, 0x28, 0x03,
0x52, 0x08, 0x74, 0x6f, 0x74, 0x61, 0x6c, 0x4f, 0x75, 0x74, 0x12, 0x22, 0x0a, 0x0c, 0x6d, 0x61,
0x74, 0x65, 0x72, 0x69, 0x61, 0x6c, 0x69, 0x7a, 0x65, 0x64, 0x18, 0x08, 0x20, 0x01, 0x28, 0x08,
0x52, 0x0c, 0x6d, 0x61, 0x74, 0x65, 0x72, 0x69, 0x61, 0x6c, 0x69, 0x7a, 0x65, 0x64, 0x12, 0x22,
0x0a, 0x0d, 0x75, 0x70, 0x64, 0x61, 0x74, 0x65, 0x64, 0x5f, 0x61, 0x74, 0x5f, 0x6d, 0x73, 0x18,
0x09, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0b, 0x75, 0x70, 0x64, 0x61, 0x74, 0x65, 0x64, 0x41, 0x74,
0x4d, 0x73, 0x22, 0xa0, 0x02, 0x0a, 0x1e, 0x47, 0x65, 0x74, 0x4c, 0x75, 0x63, 0x6b, 0x79, 0x47,
0x69, 0x66, 0x74, 0x44, 0x72, 0x61, 0x77, 0x53, 0x75, 0x6d, 0x6d, 0x61, 0x72, 0x79, 0x52, 0x65,
0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x33, 0x0a, 0x04, 0x6d, 0x65, 0x74, 0x61, 0x18, 0x01, 0x20,
0x01, 0x28, 0x0b, 0x32, 0x1f, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x6c, 0x75, 0x63, 0x6b,
0x79, 0x67, 0x69, 0x66, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74,
0x4d, 0x65, 0x74, 0x61, 0x52, 0x04, 0x6d, 0x65, 0x74, 0x61, 0x12, 0x17, 0x0a, 0x07, 0x67, 0x69,
0x66, 0x74, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x67, 0x69, 0x66,
0x74, 0x49, 0x64, 0x12, 0x17, 0x0a, 0x07, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x03,
0x20, 0x01, 0x28, 0x03, 0x52, 0x06, 0x75, 0x73, 0x65, 0x72, 0x49, 0x64, 0x12, 0x17, 0x0a, 0x07,
0x72, 0x6f, 0x6f, 0x6d, 0x5f, 0x69, 0x64, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x72,
0x6f, 0x6f, 0x6d, 0x49, 0x64, 0x12, 0x16, 0x0a, 0x06, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x18,
0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x12, 0x17, 0x0a,
0x07, 0x70, 0x6f, 0x6f, 0x6c, 0x5f, 0x69, 0x64, 0x18, 0x06, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06,
0x70, 0x6f, 0x6f, 0x6c, 0x49, 0x64, 0x12, 0x28, 0x0a, 0x10, 0x65, 0x78, 0x74, 0x65, 0x72, 0x6e,
0x61, 0x6c, 0x5f, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x08, 0x20, 0x01, 0x28, 0x09,
0x52, 0x0e, 0x65, 0x78, 0x74, 0x65, 0x72, 0x6e, 0x61, 0x6c, 0x55, 0x73, 0x65, 0x72, 0x49, 0x64,
0x12, 0x23, 0x0a, 0x0d, 0x65, 0x78, 0x74, 0x65, 0x72, 0x6e, 0x61, 0x6c, 0x5f, 0x6f, 0x6e, 0x6c,
0x79, 0x18, 0x09, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0c, 0x65, 0x78, 0x74, 0x65, 0x72, 0x6e, 0x61,
0x6c, 0x4f, 0x6e, 0x6c, 0x79, 0x22, 0x65, 0x0a, 0x1f, 0x47, 0x65, 0x74, 0x4c, 0x75, 0x63, 0x6b,
0x79, 0x47, 0x69, 0x66, 0x74, 0x44, 0x72, 0x61, 0x77, 0x53, 0x75, 0x6d, 0x6d, 0x61, 0x72, 0x79,
0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x42, 0x0a, 0x07, 0x73, 0x75, 0x6d, 0x6d,
0x61, 0x72, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x28, 0x2e, 0x68, 0x79, 0x61, 0x70,
0x70, 0x2e, 0x6c, 0x75, 0x63, 0x6b, 0x79, 0x67, 0x69, 0x66, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x4c,
0x75, 0x63, 0x6b, 0x79, 0x47, 0x69, 0x66, 0x74, 0x44, 0x72, 0x61, 0x77, 0x53, 0x75, 0x6d, 0x6d,
0x61, 0x72, 0x79, 0x52, 0x07, 0x73, 0x75, 0x6d, 0x6d, 0x61, 0x72, 0x79, 0x22, 0x70, 0x0a, 0x20,
0x4c, 0x69, 0x73, 0x74, 0x4c, 0x75, 0x63, 0x6b, 0x79, 0x47, 0x69, 0x66, 0x74, 0x50, 0x6f, 0x6f,
0x6c, 0x42, 0x61, 0x6c, 0x61, 0x6e, 0x63, 0x65, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74,
0x61, 0x12, 0x19, 0x0a, 0x08, 0x61, 0x70, 0x70, 0x5f, 0x63, 0x6f, 0x64, 0x65, 0x18, 0x03, 0x20,
0x01, 0x28, 0x09, 0x52, 0x07, 0x61, 0x70, 0x70, 0x43, 0x6f, 0x64, 0x65, 0x12, 0x28, 0x0a, 0x10,
0x65, 0x78, 0x74, 0x65, 0x72, 0x6e, 0x61, 0x6c, 0x5f, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64,
0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0e, 0x65, 0x78, 0x74, 0x65, 0x72, 0x6e, 0x61, 0x6c,
0x55, 0x73, 0x65, 0x72, 0x49, 0x64, 0x12, 0x1d, 0x0a, 0x0a, 0x72, 0x65, 0x71, 0x75, 0x65, 0x73,
0x74, 0x5f, 0x69, 0x64, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x72, 0x65, 0x71, 0x75,
0x65, 0x73, 0x74, 0x49, 0x64, 0x12, 0x1d, 0x0a, 0x0a, 0x67, 0x69, 0x66, 0x74, 0x5f, 0x63, 0x6f,
0x75, 0x6e, 0x74, 0x18, 0x06, 0x20, 0x01, 0x28, 0x03, 0x52, 0x09, 0x67, 0x69, 0x66, 0x74, 0x43,
0x6f, 0x75, 0x6e, 0x74, 0x12, 0x1f, 0x0a, 0x0b, 0x75, 0x6e, 0x69, 0x74, 0x5f, 0x61, 0x6d, 0x6f,
0x75, 0x6e, 0x74, 0x18, 0x07, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0a, 0x75, 0x6e, 0x69, 0x74, 0x41,
0x6d, 0x6f, 0x75, 0x6e, 0x74, 0x12, 0x21, 0x0a, 0x0c, 0x74, 0x6f, 0x74, 0x61, 0x6c, 0x5f, 0x61,
0x6d, 0x6f, 0x75, 0x6e, 0x74, 0x18, 0x08, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0b, 0x74, 0x6f, 0x74,
0x61, 0x6c, 0x41, 0x6d, 0x6f, 0x75, 0x6e, 0x74, 0x12, 0x1a, 0x0a, 0x08, 0x63, 0x75, 0x72, 0x72,
0x65, 0x6e, 0x63, 0x79, 0x18, 0x09, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x63, 0x75, 0x72, 0x72,
0x65, 0x6e, 0x63, 0x79, 0x12, 0x1c, 0x0a, 0x0a, 0x70, 0x61, 0x69, 0x64, 0x5f, 0x61, 0x74, 0x5f,
0x6d, 0x73, 0x18, 0x0a, 0x20, 0x01, 0x28, 0x03, 0x52, 0x08, 0x70, 0x61, 0x69, 0x64, 0x41, 0x74,
0x4d, 0x73, 0x12, 0x23, 0x0a, 0x0d, 0x6d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x5f, 0x6a,
0x73, 0x6f, 0x6e, 0x18, 0x0b, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0c, 0x6d, 0x65, 0x74, 0x61, 0x64,
0x61, 0x74, 0x61, 0x4a, 0x73, 0x6f, 0x6e, 0x12, 0x17, 0x0a, 0x07, 0x70, 0x6f, 0x6f, 0x6c, 0x5f,
0x69, 0x64, 0x18, 0x0c, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x70, 0x6f, 0x6f, 0x6c, 0x49, 0x64,
0x22, 0xb2, 0x03, 0x0a, 0x18, 0x45, 0x78, 0x74, 0x65, 0x72, 0x6e, 0x61, 0x6c, 0x47, 0x69, 0x66,
0x74, 0x44, 0x72, 0x61, 0x77, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x17, 0x0a,
0x07, 0x64, 0x72, 0x61, 0x77, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06,
0x64, 0x72, 0x61, 0x77, 0x49, 0x64, 0x12, 0x1d, 0x0a, 0x0a, 0x72, 0x65, 0x71, 0x75, 0x65, 0x73,
0x74, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x72, 0x65, 0x71, 0x75,
0x65, 0x73, 0x74, 0x49, 0x64, 0x12, 0x19, 0x0a, 0x08, 0x61, 0x70, 0x70, 0x5f, 0x63, 0x6f, 0x64,
0x65, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x61, 0x70, 0x70, 0x43, 0x6f, 0x64, 0x65,
0x12, 0x28, 0x0a, 0x10, 0x65, 0x78, 0x74, 0x65, 0x72, 0x6e, 0x61, 0x6c, 0x5f, 0x75, 0x73, 0x65,
0x72, 0x5f, 0x69, 0x64, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0e, 0x65, 0x78, 0x74, 0x65,
0x72, 0x6e, 0x61, 0x6c, 0x55, 0x73, 0x65, 0x72, 0x49, 0x64, 0x12, 0x1d, 0x0a, 0x0a, 0x67, 0x69,
0x66, 0x74, 0x5f, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x18, 0x06, 0x20, 0x01, 0x28, 0x03, 0x52, 0x09,
0x67, 0x69, 0x66, 0x74, 0x43, 0x6f, 0x75, 0x6e, 0x74, 0x12, 0x1f, 0x0a, 0x0b, 0x75, 0x6e, 0x69,
0x74, 0x5f, 0x61, 0x6d, 0x6f, 0x75, 0x6e, 0x74, 0x18, 0x07, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0a,
0x75, 0x6e, 0x69, 0x74, 0x41, 0x6d, 0x6f, 0x75, 0x6e, 0x74, 0x12, 0x21, 0x0a, 0x0c, 0x74, 0x6f,
0x74, 0x61, 0x6c, 0x5f, 0x61, 0x6d, 0x6f, 0x75, 0x6e, 0x74, 0x18, 0x08, 0x20, 0x01, 0x28, 0x03,
0x52, 0x0b, 0x74, 0x6f, 0x74, 0x61, 0x6c, 0x41, 0x6d, 0x6f, 0x75, 0x6e, 0x74, 0x12, 0x23, 0x0a,
0x0d, 0x72, 0x65, 0x77, 0x61, 0x72, 0x64, 0x5f, 0x61, 0x6d, 0x6f, 0x75, 0x6e, 0x74, 0x18, 0x09,
0x20, 0x01, 0x28, 0x03, 0x52, 0x0c, 0x72, 0x65, 0x77, 0x61, 0x72, 0x64, 0x41, 0x6d, 0x6f, 0x75,
0x6e, 0x74, 0x12, 0x25, 0x0a, 0x0e, 0x6d, 0x75, 0x6c, 0x74, 0x69, 0x70, 0x6c, 0x69, 0x65, 0x72,
0x5f, 0x70, 0x70, 0x6d, 0x18, 0x0a, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0d, 0x6d, 0x75, 0x6c, 0x74,
0x69, 0x70, 0x6c, 0x69, 0x65, 0x72, 0x50, 0x70, 0x6d, 0x12, 0x23, 0x0a, 0x0d, 0x72, 0x65, 0x77,
0x61, 0x72, 0x64, 0x5f, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x18, 0x0b, 0x20, 0x01, 0x28, 0x09,
0x52, 0x0c, 0x72, 0x65, 0x77, 0x61, 0x72, 0x64, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x12, 0x21,
0x0a, 0x0c, 0x72, 0x75, 0x6c, 0x65, 0x5f, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x0c,
0x20, 0x01, 0x28, 0x03, 0x52, 0x0b, 0x72, 0x75, 0x6c, 0x65, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f,
0x6e, 0x12, 0x22, 0x0a, 0x0d, 0x63, 0x72, 0x65, 0x61, 0x74, 0x65, 0x64, 0x5f, 0x61, 0x74, 0x5f,
0x6d, 0x73, 0x18, 0x0d, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0b, 0x63, 0x72, 0x65, 0x61, 0x74, 0x65,
0x64, 0x41, 0x74, 0x4d, 0x73, 0x22, 0x69, 0x0a, 0x19, 0x47, 0x65, 0x74, 0x4c, 0x75, 0x63, 0x6b,
0x79, 0x47, 0x69, 0x66, 0x74, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, 0x65, 0x71, 0x75, 0x65,
0x73, 0x74, 0x12, 0x33, 0x0a, 0x04, 0x6d, 0x65, 0x74, 0x61, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b,
0x32, 0x1f, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x6c, 0x75, 0x63, 0x6b, 0x79, 0x67, 0x69,
0x66, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x4d, 0x65, 0x74,
0x61, 0x52, 0x04, 0x6d, 0x65, 0x74, 0x61, 0x12, 0x17, 0x0a, 0x07, 0x70, 0x6f, 0x6f, 0x6c, 0x5f,
0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x70, 0x6f, 0x6f, 0x6c, 0x49, 0x64,
0x22, 0x5d, 0x0a, 0x1a, 0x47, 0x65, 0x74, 0x4c, 0x75, 0x63, 0x6b, 0x79, 0x47, 0x69, 0x66, 0x74,
0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x3f,
0x0a, 0x06, 0x63, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x27,
0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x6c, 0x75, 0x63, 0x6b, 0x79, 0x67, 0x69, 0x66, 0x74,
0x2e, 0x76, 0x31, 0x2e, 0x4c, 0x75, 0x63, 0x6b, 0x79, 0x47, 0x69, 0x66, 0x74, 0x52, 0x75, 0x6c,
0x65, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, 0x06, 0x63, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x22,
0xc0, 0x01, 0x0a, 0x1c, 0x55, 0x70, 0x73, 0x65, 0x72, 0x74, 0x4c, 0x75, 0x63, 0x6b, 0x79, 0x47,
0x69, 0x66, 0x74, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74,
0x12, 0x33, 0x0a, 0x04, 0x6d, 0x65, 0x74, 0x61, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1f,
0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x6c, 0x75, 0x63, 0x6b, 0x79, 0x67, 0x69, 0x66, 0x74,
0x2e, 0x76, 0x31, 0x2e, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x4d, 0x65, 0x74, 0x61, 0x52,
0x04, 0x6d, 0x65, 0x74, 0x61, 0x12, 0x17, 0x0a, 0x07, 0x70, 0x6f, 0x6f, 0x6c, 0x5f, 0x69, 0x64,
0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x70, 0x6f, 0x6f, 0x6c, 0x49, 0x64, 0x22, 0x63,
0x0a, 0x21, 0x4c, 0x69, 0x73, 0x74, 0x4c, 0x75, 0x63, 0x6b, 0x79, 0x47, 0x69, 0x66, 0x74, 0x50,
0x6f, 0x6f, 0x6c, 0x42, 0x61, 0x6c, 0x61, 0x6e, 0x63, 0x65, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f,
0x6e, 0x73, 0x65, 0x12, 0x3e, 0x0a, 0x05, 0x70, 0x6f, 0x6f, 0x6c, 0x73, 0x18, 0x01, 0x20, 0x03,
0x28, 0x0b, 0x32, 0x28, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x6c, 0x75, 0x63, 0x6b, 0x79,
0x67, 0x69, 0x66, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x4c, 0x75, 0x63, 0x6b, 0x79, 0x47, 0x69, 0x66,
0x74, 0x50, 0x6f, 0x6f, 0x6c, 0x42, 0x61, 0x6c, 0x61, 0x6e, 0x63, 0x65, 0x52, 0x05, 0x70, 0x6f,
0x6f, 0x6c, 0x73, 0x32, 0xf7, 0x03, 0x0a, 0x10, 0x4c, 0x75, 0x63, 0x6b, 0x79, 0x47, 0x69, 0x66,
0x74, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x12, 0x67, 0x0a, 0x0e, 0x43, 0x68, 0x65, 0x63,
0x6b, 0x4c, 0x75, 0x63, 0x6b, 0x79, 0x47, 0x69, 0x66, 0x74, 0x12, 0x29, 0x2e, 0x68, 0x79, 0x61,
0x70, 0x70, 0x2e, 0x6c, 0x75, 0x63, 0x6b, 0x79, 0x67, 0x69, 0x66, 0x74, 0x2e, 0x76, 0x31, 0x2e,
0x43, 0x68, 0x65, 0x63, 0x6b, 0x4c, 0x75, 0x63, 0x6b, 0x79, 0x47, 0x69, 0x66, 0x74, 0x52, 0x65,
0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x2a, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x6c, 0x75,
0x63, 0x6b, 0x79, 0x67, 0x69, 0x66, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x43, 0x68, 0x65, 0x63, 0x6b,
0x4c, 0x75, 0x63, 0x6b, 0x79, 0x47, 0x69, 0x66, 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73,
0x65, 0x12, 0x79, 0x0a, 0x14, 0x45, 0x78, 0x65, 0x63, 0x75, 0x74, 0x65, 0x4c, 0x75, 0x63, 0x6b,
0x79, 0x47, 0x69, 0x66, 0x74, 0x44, 0x72, 0x61, 0x77, 0x12, 0x2f, 0x2e, 0x68, 0x79, 0x61, 0x70,
0x70, 0x2e, 0x6c, 0x75, 0x63, 0x6b, 0x79, 0x67, 0x69, 0x66, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x45,
0x78, 0x65, 0x63, 0x75, 0x74, 0x65, 0x4c, 0x75, 0x63, 0x6b, 0x79, 0x47, 0x69, 0x66, 0x74, 0x44,
0x72, 0x61, 0x77, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x30, 0x2e, 0x68, 0x79, 0x61,
0x70, 0x70, 0x2e, 0x6c, 0x75, 0x63, 0x6b, 0x79, 0x67, 0x69, 0x66, 0x74, 0x2e, 0x76, 0x31, 0x2e,
0x45, 0x78, 0x65, 0x63, 0x75, 0x74, 0x65, 0x4c, 0x75, 0x63, 0x6b, 0x79, 0x47, 0x69, 0x66, 0x74,
0x44, 0x72, 0x61, 0x77, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x88, 0x01, 0x0a,
0x19, 0x42, 0x61, 0x74, 0x63, 0x68, 0x45, 0x78, 0x65, 0x63, 0x75, 0x74, 0x65, 0x4c, 0x75, 0x63,
0x6b, 0x79, 0x47, 0x69, 0x66, 0x74, 0x44, 0x72, 0x61, 0x77, 0x12, 0x34, 0x2e, 0x68, 0x79, 0x61,
0x70, 0x70, 0x2e, 0x6c, 0x75, 0x63, 0x6b, 0x79, 0x67, 0x69, 0x66, 0x74, 0x2e, 0x76, 0x31, 0x2e,
0x42, 0x61, 0x74, 0x63, 0x68, 0x45, 0x78, 0x65, 0x63, 0x75, 0x74, 0x65, 0x4c, 0x75, 0x63, 0x6b,
0x79, 0x47, 0x69, 0x66, 0x74, 0x44, 0x72, 0x61, 0x77, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74,
0x1a, 0x35, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x6c, 0x75, 0x63, 0x6b, 0x79, 0x67, 0x69,
0x66, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x42, 0x61, 0x74, 0x63, 0x68, 0x45, 0x78, 0x65, 0x63, 0x75,
0x74, 0x65, 0x4c, 0x75, 0x63, 0x6b, 0x79, 0x47, 0x69, 0x66, 0x74, 0x44, 0x72, 0x61, 0x77, 0x52,
0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x74, 0x0a, 0x17, 0x45, 0x78, 0x65, 0x63, 0x75,
0x74, 0x65, 0x45, 0x78, 0x74, 0x65, 0x72, 0x6e, 0x61, 0x6c, 0x47, 0x69, 0x66, 0x74, 0x44, 0x72,
0x61, 0x77, 0x12, 0x2b, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x6c, 0x75, 0x63, 0x6b, 0x79,
0x67, 0x69, 0x66, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x45, 0x78, 0x74, 0x65, 0x72, 0x6e, 0x61, 0x6c,
0x47, 0x69, 0x66, 0x74, 0x44, 0x72, 0x61, 0x77, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a,
0x2c, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x6c, 0x75, 0x63, 0x6b, 0x79, 0x67, 0x69, 0x66,
0x74, 0x2e, 0x76, 0x31, 0x2e, 0x45, 0x78, 0x74, 0x65, 0x72, 0x6e, 0x61, 0x6c, 0x47, 0x69, 0x66,
0x74, 0x44, 0x72, 0x61, 0x77, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x32, 0x8a, 0x06,
0x0a, 0x15, 0x41, 0x64, 0x6d, 0x69, 0x6e, 0x4c, 0x75, 0x63, 0x6b, 0x79, 0x47, 0x69, 0x66, 0x74,
0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x12, 0x73, 0x0a, 0x12, 0x47, 0x65, 0x74, 0x4c, 0x75,
0x63, 0x6b, 0x79, 0x47, 0x69, 0x66, 0x74, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x2d, 0x2e,
0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x6c, 0x75, 0x63, 0x6b, 0x79, 0x67, 0x69, 0x66, 0x74, 0x2e,
0x76, 0x31, 0x2e, 0x47, 0x65, 0x74, 0x4c, 0x75, 0x63, 0x6b, 0x79, 0x47, 0x69, 0x66, 0x74, 0x43,
0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x2e, 0x2e, 0x68,
0x79, 0x61, 0x70, 0x70, 0x2e, 0x6c, 0x75, 0x63, 0x6b, 0x79, 0x67, 0x69, 0x66, 0x74, 0x2e, 0x76,
0x31, 0x2e, 0x47, 0x65, 0x74, 0x4c, 0x75, 0x63, 0x6b, 0x79, 0x47, 0x69, 0x66, 0x74, 0x43, 0x6f,
0x6e, 0x66, 0x69, 0x67, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x7c, 0x0a, 0x15,
0x55, 0x70, 0x73, 0x65, 0x72, 0x74, 0x4c, 0x75, 0x63, 0x6b, 0x79, 0x47, 0x69, 0x66, 0x74, 0x43,
0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x30, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x6c, 0x75,
0x63, 0x6b, 0x79, 0x67, 0x69, 0x66, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x55, 0x70, 0x73, 0x65, 0x72,
0x74, 0x4c, 0x75, 0x63, 0x6b, 0x79, 0x47, 0x69, 0x66, 0x74, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67,
0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x31, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e,
0x6c, 0x75, 0x63, 0x6b, 0x79, 0x67, 0x69, 0x66, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x55, 0x70, 0x73,
0x65, 0x72, 0x74, 0x4c, 0x75, 0x63, 0x6b, 0x79, 0x47, 0x69, 0x66, 0x74, 0x43, 0x6f, 0x6e, 0x66,
0x69, 0x67, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x79, 0x0a, 0x14, 0x4c, 0x69,
0x73, 0x74, 0x4c, 0x75, 0x63, 0x6b, 0x79, 0x47, 0x69, 0x66, 0x74, 0x43, 0x6f, 0x6e, 0x66, 0x69,
0x67, 0x73, 0x12, 0x2f, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x6c, 0x75, 0x63, 0x6b, 0x79,
0x67, 0x69, 0x66, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x4c, 0x69, 0x73, 0x74, 0x4c, 0x75, 0x63, 0x6b,
0x04, 0x6d, 0x65, 0x74, 0x61, 0x12, 0x3f, 0x0a, 0x06, 0x63, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x18,
0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x27, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x6c, 0x75,
0x63, 0x6b, 0x79, 0x67, 0x69, 0x66, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x4c, 0x75, 0x63, 0x6b, 0x79,
0x47, 0x69, 0x66, 0x74, 0x52, 0x75, 0x6c, 0x65, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, 0x06,
0x63, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x2a, 0x0a, 0x11, 0x6f, 0x70, 0x65, 0x72, 0x61, 0x74,
0x6f, 0x72, 0x5f, 0x61, 0x64, 0x6d, 0x69, 0x6e, 0x5f, 0x69, 0x64, 0x18, 0x03, 0x20, 0x01, 0x28,
0x03, 0x52, 0x0f, 0x6f, 0x70, 0x65, 0x72, 0x61, 0x74, 0x6f, 0x72, 0x41, 0x64, 0x6d, 0x69, 0x6e,
0x49, 0x64, 0x22, 0x60, 0x0a, 0x1d, 0x55, 0x70, 0x73, 0x65, 0x72, 0x74, 0x4c, 0x75, 0x63, 0x6b,
0x79, 0x47, 0x69, 0x66, 0x74, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, 0x65, 0x73, 0x70, 0x6f,
0x6e, 0x73, 0x65, 0x12, 0x3f, 0x0a, 0x06, 0x63, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x18, 0x01, 0x20,
0x01, 0x28, 0x0b, 0x32, 0x27, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x6c, 0x75, 0x63, 0x6b,
0x79, 0x67, 0x69, 0x66, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x4c, 0x75, 0x63, 0x6b, 0x79, 0x47, 0x69,
0x66, 0x74, 0x52, 0x75, 0x6c, 0x65, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, 0x06, 0x63, 0x6f,
0x6e, 0x66, 0x69, 0x67, 0x22, 0x52, 0x0a, 0x1b, 0x4c, 0x69, 0x73, 0x74, 0x4c, 0x75, 0x63, 0x6b,
0x79, 0x47, 0x69, 0x66, 0x74, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x73, 0x52, 0x65, 0x71, 0x75,
0x65, 0x73, 0x74, 0x1a, 0x30, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x6c, 0x75, 0x63, 0x6b,
0x79, 0x67, 0x69, 0x66, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x4c, 0x69, 0x73, 0x74, 0x4c, 0x75, 0x63,
0x6b, 0x79, 0x47, 0x69, 0x66, 0x74, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x73, 0x52, 0x65, 0x73,
0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x73, 0x0a, 0x12, 0x4c, 0x69, 0x73, 0x74, 0x4c, 0x75, 0x63,
0x6b, 0x79, 0x47, 0x69, 0x66, 0x74, 0x44, 0x72, 0x61, 0x77, 0x73, 0x12, 0x2d, 0x2e, 0x68, 0x79,
0x61, 0x70, 0x70, 0x2e, 0x6c, 0x75, 0x63, 0x6b, 0x79, 0x67, 0x69, 0x66, 0x74, 0x2e, 0x76, 0x31,
0x2e, 0x4c, 0x69, 0x73, 0x74, 0x4c, 0x75, 0x63, 0x6b, 0x79, 0x47, 0x69, 0x66, 0x74, 0x44, 0x72,
0x61, 0x77, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x2e, 0x2e, 0x68, 0x79, 0x61,
0x70, 0x70, 0x2e, 0x6c, 0x75, 0x63, 0x6b, 0x79, 0x67, 0x69, 0x66, 0x74, 0x2e, 0x76, 0x31, 0x2e,
0x65, 0x73, 0x74, 0x12, 0x33, 0x0a, 0x04, 0x6d, 0x65, 0x74, 0x61, 0x18, 0x01, 0x20, 0x01, 0x28,
0x0b, 0x32, 0x1f, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x6c, 0x75, 0x63, 0x6b, 0x79, 0x67,
0x69, 0x66, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x4d, 0x65,
0x74, 0x61, 0x52, 0x04, 0x6d, 0x65, 0x74, 0x61, 0x22, 0x61, 0x0a, 0x1c, 0x4c, 0x69, 0x73, 0x74,
0x4c, 0x75, 0x63, 0x6b, 0x79, 0x47, 0x69, 0x66, 0x74, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x73,
0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x41, 0x0a, 0x07, 0x63, 0x6f, 0x6e, 0x66,
0x69, 0x67, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x27, 0x2e, 0x68, 0x79, 0x61, 0x70,
0x70, 0x2e, 0x6c, 0x75, 0x63, 0x6b, 0x79, 0x67, 0x69, 0x66, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x4c,
0x75, 0x63, 0x6b, 0x79, 0x47, 0x69, 0x66, 0x74, 0x52, 0x75, 0x6c, 0x65, 0x43, 0x6f, 0x6e, 0x66,
0x69, 0x67, 0x52, 0x07, 0x63, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x73, 0x22, 0xcc, 0x02, 0x0a, 0x19,
0x4c, 0x69, 0x73, 0x74, 0x4c, 0x75, 0x63, 0x6b, 0x79, 0x47, 0x69, 0x66, 0x74, 0x44, 0x72, 0x61,
0x77, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x82, 0x01, 0x0a, 0x17, 0x47,
0x77, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x33, 0x0a, 0x04, 0x6d, 0x65, 0x74,
0x61, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1f, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e,
0x6c, 0x75, 0x63, 0x6b, 0x79, 0x67, 0x69, 0x66, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x52, 0x65, 0x71,
0x75, 0x65, 0x73, 0x74, 0x4d, 0x65, 0x74, 0x61, 0x52, 0x04, 0x6d, 0x65, 0x74, 0x61, 0x12, 0x17,
0x0a, 0x07, 0x67, 0x69, 0x66, 0x74, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52,
0x06, 0x67, 0x69, 0x66, 0x74, 0x49, 0x64, 0x12, 0x17, 0x0a, 0x07, 0x75, 0x73, 0x65, 0x72, 0x5f,
0x69, 0x64, 0x18, 0x03, 0x20, 0x01, 0x28, 0x03, 0x52, 0x06, 0x75, 0x73, 0x65, 0x72, 0x49, 0x64,
0x12, 0x17, 0x0a, 0x07, 0x72, 0x6f, 0x6f, 0x6d, 0x5f, 0x69, 0x64, 0x18, 0x04, 0x20, 0x01, 0x28,
0x09, 0x52, 0x06, 0x72, 0x6f, 0x6f, 0x6d, 0x49, 0x64, 0x12, 0x16, 0x0a, 0x06, 0x73, 0x74, 0x61,
0x74, 0x75, 0x73, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x73, 0x74, 0x61, 0x74, 0x75,
0x73, 0x12, 0x12, 0x0a, 0x04, 0x70, 0x61, 0x67, 0x65, 0x18, 0x06, 0x20, 0x01, 0x28, 0x05, 0x52,
0x04, 0x70, 0x61, 0x67, 0x65, 0x12, 0x1b, 0x0a, 0x09, 0x70, 0x61, 0x67, 0x65, 0x5f, 0x73, 0x69,
0x7a, 0x65, 0x18, 0x07, 0x20, 0x01, 0x28, 0x05, 0x52, 0x08, 0x70, 0x61, 0x67, 0x65, 0x53, 0x69,
0x7a, 0x65, 0x12, 0x17, 0x0a, 0x07, 0x70, 0x6f, 0x6f, 0x6c, 0x5f, 0x69, 0x64, 0x18, 0x08, 0x20,
0x01, 0x28, 0x09, 0x52, 0x06, 0x70, 0x6f, 0x6f, 0x6c, 0x49, 0x64, 0x12, 0x28, 0x0a, 0x10, 0x65,
0x78, 0x74, 0x65, 0x72, 0x6e, 0x61, 0x6c, 0x5f, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18,
0x0a, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0e, 0x65, 0x78, 0x74, 0x65, 0x72, 0x6e, 0x61, 0x6c, 0x55,
0x73, 0x65, 0x72, 0x49, 0x64, 0x12, 0x23, 0x0a, 0x0d, 0x65, 0x78, 0x74, 0x65, 0x72, 0x6e, 0x61,
0x6c, 0x5f, 0x6f, 0x6e, 0x6c, 0x79, 0x18, 0x0b, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0c, 0x65, 0x78,
0x74, 0x65, 0x72, 0x6e, 0x61, 0x6c, 0x4f, 0x6e, 0x6c, 0x79, 0x22, 0x71, 0x0a, 0x1a, 0x4c, 0x69,
0x73, 0x74, 0x4c, 0x75, 0x63, 0x6b, 0x79, 0x47, 0x69, 0x66, 0x74, 0x44, 0x72, 0x61, 0x77, 0x73,
0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x3d, 0x0a, 0x05, 0x64, 0x72, 0x61, 0x77,
0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x27, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e,
0x6c, 0x75, 0x63, 0x6b, 0x79, 0x67, 0x69, 0x66, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x4c, 0x75, 0x63,
0x6b, 0x79, 0x47, 0x69, 0x66, 0x74, 0x44, 0x72, 0x61, 0x77, 0x52, 0x65, 0x73, 0x75, 0x6c, 0x74,
0x52, 0x05, 0x64, 0x72, 0x61, 0x77, 0x73, 0x12, 0x14, 0x0a, 0x05, 0x74, 0x6f, 0x74, 0x61, 0x6c,
0x18, 0x02, 0x20, 0x01, 0x28, 0x03, 0x52, 0x05, 0x74, 0x6f, 0x74, 0x61, 0x6c, 0x22, 0xaf, 0x03,
0x0a, 0x14, 0x4c, 0x75, 0x63, 0x6b, 0x79, 0x47, 0x69, 0x66, 0x74, 0x44, 0x72, 0x61, 0x77, 0x53,
0x75, 0x6d, 0x6d, 0x61, 0x72, 0x79, 0x12, 0x17, 0x0a, 0x07, 0x70, 0x6f, 0x6f, 0x6c, 0x5f, 0x69,
0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x70, 0x6f, 0x6f, 0x6c, 0x49, 0x64, 0x12,
0x1f, 0x0a, 0x0b, 0x74, 0x6f, 0x74, 0x61, 0x6c, 0x5f, 0x64, 0x72, 0x61, 0x77, 0x73, 0x18, 0x02,
0x20, 0x01, 0x28, 0x03, 0x52, 0x0a, 0x74, 0x6f, 0x74, 0x61, 0x6c, 0x44, 0x72, 0x61, 0x77, 0x73,
0x12, 0x21, 0x0a, 0x0c, 0x75, 0x6e, 0x69, 0x71, 0x75, 0x65, 0x5f, 0x75, 0x73, 0x65, 0x72, 0x73,
0x18, 0x03, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0b, 0x75, 0x6e, 0x69, 0x71, 0x75, 0x65, 0x55, 0x73,
0x65, 0x72, 0x73, 0x12, 0x21, 0x0a, 0x0c, 0x75, 0x6e, 0x69, 0x71, 0x75, 0x65, 0x5f, 0x72, 0x6f,
0x6f, 0x6d, 0x73, 0x18, 0x04, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0b, 0x75, 0x6e, 0x69, 0x71, 0x75,
0x65, 0x52, 0x6f, 0x6f, 0x6d, 0x73, 0x12, 0x2a, 0x0a, 0x11, 0x74, 0x6f, 0x74, 0x61, 0x6c, 0x5f,
0x73, 0x70, 0x65, 0x6e, 0x74, 0x5f, 0x63, 0x6f, 0x69, 0x6e, 0x73, 0x18, 0x05, 0x20, 0x01, 0x28,
0x03, 0x52, 0x0f, 0x74, 0x6f, 0x74, 0x61, 0x6c, 0x53, 0x70, 0x65, 0x6e, 0x74, 0x43, 0x6f, 0x69,
0x6e, 0x73, 0x12, 0x2c, 0x0a, 0x12, 0x74, 0x6f, 0x74, 0x61, 0x6c, 0x5f, 0x72, 0x65, 0x77, 0x61,
0x72, 0x64, 0x5f, 0x63, 0x6f, 0x69, 0x6e, 0x73, 0x18, 0x06, 0x20, 0x01, 0x28, 0x03, 0x52, 0x10,
0x74, 0x6f, 0x74, 0x61, 0x6c, 0x52, 0x65, 0x77, 0x61, 0x72, 0x64, 0x43, 0x6f, 0x69, 0x6e, 0x73,
0x12, 0x2a, 0x0a, 0x11, 0x62, 0x61, 0x73, 0x65, 0x5f, 0x72, 0x65, 0x77, 0x61, 0x72, 0x64, 0x5f,
0x63, 0x6f, 0x69, 0x6e, 0x73, 0x18, 0x07, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0f, 0x62, 0x61, 0x73,
0x65, 0x52, 0x65, 0x77, 0x61, 0x72, 0x64, 0x43, 0x6f, 0x69, 0x6e, 0x73, 0x12, 0x24, 0x0a, 0x0e,
0x61, 0x63, 0x74, 0x75, 0x61, 0x6c, 0x5f, 0x72, 0x74, 0x70, 0x5f, 0x70, 0x70, 0x6d, 0x18, 0x08,
0x20, 0x01, 0x28, 0x03, 0x52, 0x0c, 0x61, 0x63, 0x74, 0x75, 0x61, 0x6c, 0x52, 0x74, 0x70, 0x50,
0x70, 0x6d, 0x12, 0x23, 0x0a, 0x0d, 0x70, 0x65, 0x6e, 0x64, 0x69, 0x6e, 0x67, 0x5f, 0x64, 0x72,
0x61, 0x77, 0x73, 0x18, 0x09, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0c, 0x70, 0x65, 0x6e, 0x64, 0x69,
0x6e, 0x67, 0x44, 0x72, 0x61, 0x77, 0x73, 0x12, 0x23, 0x0a, 0x0d, 0x67, 0x72, 0x61, 0x6e, 0x74,
0x65, 0x64, 0x5f, 0x64, 0x72, 0x61, 0x77, 0x73, 0x18, 0x0a, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0c,
0x67, 0x72, 0x61, 0x6e, 0x74, 0x65, 0x64, 0x44, 0x72, 0x61, 0x77, 0x73, 0x12, 0x21, 0x0a, 0x0c,
0x66, 0x61, 0x69, 0x6c, 0x65, 0x64, 0x5f, 0x64, 0x72, 0x61, 0x77, 0x73, 0x18, 0x0b, 0x20, 0x01,
0x28, 0x03, 0x52, 0x0b, 0x66, 0x61, 0x69, 0x6c, 0x65, 0x64, 0x44, 0x72, 0x61, 0x77, 0x73, 0x22,
0xb6, 0x02, 0x0a, 0x14, 0x4c, 0x75, 0x63, 0x6b, 0x79, 0x47, 0x69, 0x66, 0x74, 0x50, 0x6f, 0x6f,
0x6c, 0x42, 0x61, 0x6c, 0x61, 0x6e, 0x63, 0x65, 0x12, 0x19, 0x0a, 0x08, 0x61, 0x70, 0x70, 0x5f,
0x63, 0x6f, 0x64, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x61, 0x70, 0x70, 0x43,
0x6f, 0x64, 0x65, 0x12, 0x17, 0x0a, 0x07, 0x70, 0x6f, 0x6f, 0x6c, 0x5f, 0x69, 0x64, 0x18, 0x02,
0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x70, 0x6f, 0x6f, 0x6c, 0x49, 0x64, 0x12, 0x18, 0x0a, 0x07,
0x62, 0x61, 0x6c, 0x61, 0x6e, 0x63, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x03, 0x52, 0x07, 0x62,
0x61, 0x6c, 0x61, 0x6e, 0x63, 0x65, 0x12, 0x23, 0x0a, 0x0d, 0x72, 0x65, 0x73, 0x65, 0x72, 0x76,
0x65, 0x5f, 0x66, 0x6c, 0x6f, 0x6f, 0x72, 0x18, 0x04, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0c, 0x72,
0x65, 0x73, 0x65, 0x72, 0x76, 0x65, 0x46, 0x6c, 0x6f, 0x6f, 0x72, 0x12, 0x2b, 0x0a, 0x11, 0x61,
0x76, 0x61, 0x69, 0x6c, 0x61, 0x62, 0x6c, 0x65, 0x5f, 0x62, 0x61, 0x6c, 0x61, 0x6e, 0x63, 0x65,
0x18, 0x05, 0x20, 0x01, 0x28, 0x03, 0x52, 0x10, 0x61, 0x76, 0x61, 0x69, 0x6c, 0x61, 0x62, 0x6c,
0x65, 0x42, 0x61, 0x6c, 0x61, 0x6e, 0x63, 0x65, 0x12, 0x19, 0x0a, 0x08, 0x74, 0x6f, 0x74, 0x61,
0x6c, 0x5f, 0x69, 0x6e, 0x18, 0x06, 0x20, 0x01, 0x28, 0x03, 0x52, 0x07, 0x74, 0x6f, 0x74, 0x61,
0x6c, 0x49, 0x6e, 0x12, 0x1b, 0x0a, 0x09, 0x74, 0x6f, 0x74, 0x61, 0x6c, 0x5f, 0x6f, 0x75, 0x74,
0x18, 0x07, 0x20, 0x01, 0x28, 0x03, 0x52, 0x08, 0x74, 0x6f, 0x74, 0x61, 0x6c, 0x4f, 0x75, 0x74,
0x12, 0x22, 0x0a, 0x0c, 0x6d, 0x61, 0x74, 0x65, 0x72, 0x69, 0x61, 0x6c, 0x69, 0x7a, 0x65, 0x64,
0x18, 0x08, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0c, 0x6d, 0x61, 0x74, 0x65, 0x72, 0x69, 0x61, 0x6c,
0x69, 0x7a, 0x65, 0x64, 0x12, 0x22, 0x0a, 0x0d, 0x75, 0x70, 0x64, 0x61, 0x74, 0x65, 0x64, 0x5f,
0x61, 0x74, 0x5f, 0x6d, 0x73, 0x18, 0x09, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0b, 0x75, 0x70, 0x64,
0x61, 0x74, 0x65, 0x64, 0x41, 0x74, 0x4d, 0x73, 0x22, 0xa0, 0x02, 0x0a, 0x1e, 0x47, 0x65, 0x74,
0x4c, 0x75, 0x63, 0x6b, 0x79, 0x47, 0x69, 0x66, 0x74, 0x44, 0x72, 0x61, 0x77, 0x53, 0x75, 0x6d,
0x6d, 0x61, 0x72, 0x79, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x33, 0x0a, 0x04, 0x6d,
0x65, 0x74, 0x61, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1f, 0x2e, 0x68, 0x79, 0x61, 0x70,
0x70, 0x2e, 0x6c, 0x75, 0x63, 0x6b, 0x79, 0x67, 0x69, 0x66, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x52,
0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x4d, 0x65, 0x74, 0x61, 0x52, 0x04, 0x6d, 0x65, 0x74, 0x61,
0x12, 0x17, 0x0a, 0x07, 0x67, 0x69, 0x66, 0x74, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28,
0x09, 0x52, 0x06, 0x67, 0x69, 0x66, 0x74, 0x49, 0x64, 0x12, 0x17, 0x0a, 0x07, 0x75, 0x73, 0x65,
0x72, 0x5f, 0x69, 0x64, 0x18, 0x03, 0x20, 0x01, 0x28, 0x03, 0x52, 0x06, 0x75, 0x73, 0x65, 0x72,
0x49, 0x64, 0x12, 0x17, 0x0a, 0x07, 0x72, 0x6f, 0x6f, 0x6d, 0x5f, 0x69, 0x64, 0x18, 0x04, 0x20,
0x01, 0x28, 0x09, 0x52, 0x06, 0x72, 0x6f, 0x6f, 0x6d, 0x49, 0x64, 0x12, 0x16, 0x0a, 0x06, 0x73,
0x74, 0x61, 0x74, 0x75, 0x73, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x73, 0x74, 0x61,
0x74, 0x75, 0x73, 0x12, 0x17, 0x0a, 0x07, 0x70, 0x6f, 0x6f, 0x6c, 0x5f, 0x69, 0x64, 0x18, 0x06,
0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x70, 0x6f, 0x6f, 0x6c, 0x49, 0x64, 0x12, 0x28, 0x0a, 0x10,
0x65, 0x78, 0x74, 0x65, 0x72, 0x6e, 0x61, 0x6c, 0x5f, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64,
0x18, 0x08, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0e, 0x65, 0x78, 0x74, 0x65, 0x72, 0x6e, 0x61, 0x6c,
0x55, 0x73, 0x65, 0x72, 0x49, 0x64, 0x12, 0x23, 0x0a, 0x0d, 0x65, 0x78, 0x74, 0x65, 0x72, 0x6e,
0x61, 0x6c, 0x5f, 0x6f, 0x6e, 0x6c, 0x79, 0x18, 0x09, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0c, 0x65,
0x78, 0x74, 0x65, 0x72, 0x6e, 0x61, 0x6c, 0x4f, 0x6e, 0x6c, 0x79, 0x22, 0x65, 0x0a, 0x1f, 0x47,
0x65, 0x74, 0x4c, 0x75, 0x63, 0x6b, 0x79, 0x47, 0x69, 0x66, 0x74, 0x44, 0x72, 0x61, 0x77, 0x53,
0x75, 0x6d, 0x6d, 0x61, 0x72, 0x79, 0x12, 0x32, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x6c,
0x75, 0x63, 0x6b, 0x79, 0x67, 0x69, 0x66, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x47, 0x65, 0x74, 0x4c,
0x75, 0x63, 0x6b, 0x79, 0x47, 0x69, 0x66, 0x74, 0x44, 0x72, 0x61, 0x77, 0x53, 0x75, 0x6d, 0x6d,
0x61, 0x72, 0x79, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x33, 0x2e, 0x68, 0x79, 0x61,
0x75, 0x6d, 0x6d, 0x61, 0x72, 0x79, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x42,
0x0a, 0x07, 0x73, 0x75, 0x6d, 0x6d, 0x61, 0x72, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32,
0x28, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x6c, 0x75, 0x63, 0x6b, 0x79, 0x67, 0x69, 0x66,
0x74, 0x2e, 0x76, 0x31, 0x2e, 0x4c, 0x75, 0x63, 0x6b, 0x79, 0x47, 0x69, 0x66, 0x74, 0x44, 0x72,
0x61, 0x77, 0x53, 0x75, 0x6d, 0x6d, 0x61, 0x72, 0x79, 0x52, 0x07, 0x73, 0x75, 0x6d, 0x6d, 0x61,
0x72, 0x79, 0x22, 0x70, 0x0a, 0x20, 0x4c, 0x69, 0x73, 0x74, 0x4c, 0x75, 0x63, 0x6b, 0x79, 0x47,
0x69, 0x66, 0x74, 0x50, 0x6f, 0x6f, 0x6c, 0x42, 0x61, 0x6c, 0x61, 0x6e, 0x63, 0x65, 0x73, 0x52,
0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x33, 0x0a, 0x04, 0x6d, 0x65, 0x74, 0x61, 0x18, 0x01,
0x20, 0x01, 0x28, 0x0b, 0x32, 0x1f, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x6c, 0x75, 0x63,
0x6b, 0x79, 0x67, 0x69, 0x66, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73,
0x74, 0x4d, 0x65, 0x74, 0x61, 0x52, 0x04, 0x6d, 0x65, 0x74, 0x61, 0x12, 0x17, 0x0a, 0x07, 0x70,
0x6f, 0x6f, 0x6c, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x70, 0x6f,
0x6f, 0x6c, 0x49, 0x64, 0x22, 0x63, 0x0a, 0x21, 0x4c, 0x69, 0x73, 0x74, 0x4c, 0x75, 0x63, 0x6b,
0x79, 0x47, 0x69, 0x66, 0x74, 0x50, 0x6f, 0x6f, 0x6c, 0x42, 0x61, 0x6c, 0x61, 0x6e, 0x63, 0x65,
0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x3e, 0x0a, 0x05, 0x70, 0x6f, 0x6f,
0x6c, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x28, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70,
0x2e, 0x6c, 0x75, 0x63, 0x6b, 0x79, 0x67, 0x69, 0x66, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x4c, 0x75,
0x63, 0x6b, 0x79, 0x47, 0x69, 0x66, 0x74, 0x50, 0x6f, 0x6f, 0x6c, 0x42, 0x61, 0x6c, 0x61, 0x6e,
0x63, 0x65, 0x52, 0x05, 0x70, 0x6f, 0x6f, 0x6c, 0x73, 0x22, 0xba, 0x01, 0x0a, 0x0c, 0x4c, 0x75,
0x63, 0x6b, 0x79, 0x47, 0x69, 0x66, 0x74, 0x48, 0x69, 0x74, 0x12, 0x1d, 0x0a, 0x0a, 0x67, 0x69,
0x66, 0x74, 0x5f, 0x69, 0x6e, 0x64, 0x65, 0x78, 0x18, 0x01, 0x20, 0x01, 0x28, 0x05, 0x52, 0x09,
0x67, 0x69, 0x66, 0x74, 0x49, 0x6e, 0x64, 0x65, 0x78, 0x12, 0x17, 0x0a, 0x07, 0x64, 0x72, 0x61,
0x77, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x64, 0x72, 0x61, 0x77,
0x49, 0x64, 0x12, 0x28, 0x0a, 0x10, 0x73, 0x65, 0x6c, 0x65, 0x63, 0x74, 0x65, 0x64, 0x5f, 0x74,
0x69, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0e, 0x73, 0x65,
0x6c, 0x65, 0x63, 0x74, 0x65, 0x64, 0x54, 0x69, 0x65, 0x72, 0x49, 0x64, 0x12, 0x25, 0x0a, 0x0e,
0x6d, 0x75, 0x6c, 0x74, 0x69, 0x70, 0x6c, 0x69, 0x65, 0x72, 0x5f, 0x70, 0x70, 0x6d, 0x18, 0x04,
0x20, 0x01, 0x28, 0x03, 0x52, 0x0d, 0x6d, 0x75, 0x6c, 0x74, 0x69, 0x70, 0x6c, 0x69, 0x65, 0x72,
0x50, 0x70, 0x6d, 0x12, 0x21, 0x0a, 0x0c, 0x72, 0x65, 0x77, 0x61, 0x72, 0x64, 0x5f, 0x63, 0x6f,
0x69, 0x6e, 0x73, 0x18, 0x05, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0b, 0x72, 0x65, 0x77, 0x61, 0x72,
0x64, 0x43, 0x6f, 0x69, 0x6e, 0x73, 0x32, 0xf7, 0x03, 0x0a, 0x10, 0x4c, 0x75, 0x63, 0x6b, 0x79,
0x47, 0x69, 0x66, 0x74, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x12, 0x67, 0x0a, 0x0e, 0x43,
0x68, 0x65, 0x63, 0x6b, 0x4c, 0x75, 0x63, 0x6b, 0x79, 0x47, 0x69, 0x66, 0x74, 0x12, 0x29, 0x2e,
0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x6c, 0x75, 0x63, 0x6b, 0x79, 0x67, 0x69, 0x66, 0x74, 0x2e,
0x76, 0x31, 0x2e, 0x43, 0x68, 0x65, 0x63, 0x6b, 0x4c, 0x75, 0x63, 0x6b, 0x79, 0x47, 0x69, 0x66,
0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x2a, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70,
0x2e, 0x6c, 0x75, 0x63, 0x6b, 0x79, 0x67, 0x69, 0x66, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x43, 0x68,
0x65, 0x63, 0x6b, 0x4c, 0x75, 0x63, 0x6b, 0x79, 0x47, 0x69, 0x66, 0x74, 0x52, 0x65, 0x73, 0x70,
0x6f, 0x6e, 0x73, 0x65, 0x12, 0x79, 0x0a, 0x14, 0x45, 0x78, 0x65, 0x63, 0x75, 0x74, 0x65, 0x4c,
0x75, 0x63, 0x6b, 0x79, 0x47, 0x69, 0x66, 0x74, 0x44, 0x72, 0x61, 0x77, 0x12, 0x2f, 0x2e, 0x68,
0x79, 0x61, 0x70, 0x70, 0x2e, 0x6c, 0x75, 0x63, 0x6b, 0x79, 0x67, 0x69, 0x66, 0x74, 0x2e, 0x76,
0x31, 0x2e, 0x45, 0x78, 0x65, 0x63, 0x75, 0x74, 0x65, 0x4c, 0x75, 0x63, 0x6b, 0x79, 0x47, 0x69,
0x66, 0x74, 0x44, 0x72, 0x61, 0x77, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x30, 0x2e,
0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x6c, 0x75, 0x63, 0x6b, 0x79, 0x67, 0x69, 0x66, 0x74, 0x2e,
0x76, 0x31, 0x2e, 0x45, 0x78, 0x65, 0x63, 0x75, 0x74, 0x65, 0x4c, 0x75, 0x63, 0x6b, 0x79, 0x47,
0x69, 0x66, 0x74, 0x44, 0x72, 0x61, 0x77, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12,
0x88, 0x01, 0x0a, 0x19, 0x42, 0x61, 0x74, 0x63, 0x68, 0x45, 0x78, 0x65, 0x63, 0x75, 0x74, 0x65,
0x4c, 0x75, 0x63, 0x6b, 0x79, 0x47, 0x69, 0x66, 0x74, 0x44, 0x72, 0x61, 0x77, 0x12, 0x34, 0x2e,
0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x6c, 0x75, 0x63, 0x6b, 0x79, 0x67, 0x69, 0x66, 0x74, 0x2e,
0x76, 0x31, 0x2e, 0x42, 0x61, 0x74, 0x63, 0x68, 0x45, 0x78, 0x65, 0x63, 0x75, 0x74, 0x65, 0x4c,
0x75, 0x63, 0x6b, 0x79, 0x47, 0x69, 0x66, 0x74, 0x44, 0x72, 0x61, 0x77, 0x52, 0x65, 0x71, 0x75,
0x65, 0x73, 0x74, 0x1a, 0x35, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x6c, 0x75, 0x63, 0x6b,
0x79, 0x67, 0x69, 0x66, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x42, 0x61, 0x74, 0x63, 0x68, 0x45, 0x78,
0x65, 0x63, 0x75, 0x74, 0x65, 0x4c, 0x75, 0x63, 0x6b, 0x79, 0x47, 0x69, 0x66, 0x74, 0x44, 0x72,
0x61, 0x77, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x74, 0x0a, 0x17, 0x45, 0x78,
0x65, 0x63, 0x75, 0x74, 0x65, 0x45, 0x78, 0x74, 0x65, 0x72, 0x6e, 0x61, 0x6c, 0x47, 0x69, 0x66,
0x74, 0x44, 0x72, 0x61, 0x77, 0x12, 0x2b, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x6c, 0x75,
0x63, 0x6b, 0x79, 0x67, 0x69, 0x66, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x45, 0x78, 0x74, 0x65, 0x72,
0x6e, 0x61, 0x6c, 0x47, 0x69, 0x66, 0x74, 0x44, 0x72, 0x61, 0x77, 0x52, 0x65, 0x71, 0x75, 0x65,
0x73, 0x74, 0x1a, 0x2c, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x6c, 0x75, 0x63, 0x6b, 0x79,
0x67, 0x69, 0x66, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x45, 0x78, 0x74, 0x65, 0x72, 0x6e, 0x61, 0x6c,
0x47, 0x69, 0x66, 0x74, 0x44, 0x72, 0x61, 0x77, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65,
0x32, 0x8a, 0x06, 0x0a, 0x15, 0x41, 0x64, 0x6d, 0x69, 0x6e, 0x4c, 0x75, 0x63, 0x6b, 0x79, 0x47,
0x69, 0x66, 0x74, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x12, 0x73, 0x0a, 0x12, 0x47, 0x65,
0x74, 0x4c, 0x75, 0x63, 0x6b, 0x79, 0x47, 0x69, 0x66, 0x74, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67,
0x12, 0x2d, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x6c, 0x75, 0x63, 0x6b, 0x79, 0x67, 0x69,
0x66, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x47, 0x65, 0x74, 0x4c, 0x75, 0x63, 0x6b, 0x79, 0x47, 0x69,
0x66, 0x74, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a,
0x2e, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x6c, 0x75, 0x63, 0x6b, 0x79, 0x67, 0x69, 0x66,
0x74, 0x2e, 0x76, 0x31, 0x2e, 0x47, 0x65, 0x74, 0x4c, 0x75, 0x63, 0x6b, 0x79, 0x47, 0x69, 0x66,
0x74, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12,
0x7c, 0x0a, 0x15, 0x55, 0x70, 0x73, 0x65, 0x72, 0x74, 0x4c, 0x75, 0x63, 0x6b, 0x79, 0x47, 0x69,
0x66, 0x74, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x30, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70,
0x2e, 0x6c, 0x75, 0x63, 0x6b, 0x79, 0x67, 0x69, 0x66, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x55, 0x70,
0x73, 0x65, 0x72, 0x74, 0x4c, 0x75, 0x63, 0x6b, 0x79, 0x47, 0x69, 0x66, 0x74, 0x43, 0x6f, 0x6e,
0x66, 0x69, 0x67, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x31, 0x2e, 0x68, 0x79, 0x61,
0x70, 0x70, 0x2e, 0x6c, 0x75, 0x63, 0x6b, 0x79, 0x67, 0x69, 0x66, 0x74, 0x2e, 0x76, 0x31, 0x2e,
0x47, 0x65, 0x74, 0x4c, 0x75, 0x63, 0x6b, 0x79, 0x47, 0x69, 0x66, 0x74, 0x44, 0x72, 0x61, 0x77,
0x53, 0x75, 0x6d, 0x6d, 0x61, 0x72, 0x79, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12,
0x88, 0x01, 0x0a, 0x19, 0x4c, 0x69, 0x73, 0x74, 0x4c, 0x75, 0x63, 0x6b, 0x79, 0x47, 0x69, 0x66,
0x74, 0x50, 0x6f, 0x6f, 0x6c, 0x42, 0x61, 0x6c, 0x61, 0x6e, 0x63, 0x65, 0x73, 0x12, 0x34, 0x2e,
0x55, 0x70, 0x73, 0x65, 0x72, 0x74, 0x4c, 0x75, 0x63, 0x6b, 0x79, 0x47, 0x69, 0x66, 0x74, 0x43,
0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x79, 0x0a,
0x14, 0x4c, 0x69, 0x73, 0x74, 0x4c, 0x75, 0x63, 0x6b, 0x79, 0x47, 0x69, 0x66, 0x74, 0x43, 0x6f,
0x6e, 0x66, 0x69, 0x67, 0x73, 0x12, 0x2f, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x6c, 0x75,
0x63, 0x6b, 0x79, 0x67, 0x69, 0x66, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x4c, 0x69, 0x73, 0x74, 0x4c,
0x75, 0x63, 0x6b, 0x79, 0x47, 0x69, 0x66, 0x74, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x73, 0x52,
0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x30, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x6c,
0x75, 0x63, 0x6b, 0x79, 0x67, 0x69, 0x66, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x4c, 0x69, 0x73, 0x74,
0x4c, 0x75, 0x63, 0x6b, 0x79, 0x47, 0x69, 0x66, 0x74, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x73,
0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x73, 0x0a, 0x12, 0x4c, 0x69, 0x73, 0x74,
0x4c, 0x75, 0x63, 0x6b, 0x79, 0x47, 0x69, 0x66, 0x74, 0x44, 0x72, 0x61, 0x77, 0x73, 0x12, 0x2d,
0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x6c, 0x75, 0x63, 0x6b, 0x79, 0x67, 0x69, 0x66, 0x74,
0x2e, 0x76, 0x31, 0x2e, 0x4c, 0x69, 0x73, 0x74, 0x4c, 0x75, 0x63, 0x6b, 0x79, 0x47, 0x69, 0x66,
0x74, 0x44, 0x72, 0x61, 0x77, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x2e, 0x2e,
0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x6c, 0x75, 0x63, 0x6b, 0x79, 0x67, 0x69, 0x66, 0x74, 0x2e,
0x76, 0x31, 0x2e, 0x4c, 0x69, 0x73, 0x74, 0x4c, 0x75, 0x63, 0x6b, 0x79, 0x47, 0x69, 0x66, 0x74,
0x50, 0x6f, 0x6f, 0x6c, 0x42, 0x61, 0x6c, 0x61, 0x6e, 0x63, 0x65, 0x73, 0x52, 0x65, 0x71, 0x75,
0x65, 0x73, 0x74, 0x1a, 0x35, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x6c, 0x75, 0x63, 0x6b,
0x79, 0x67, 0x69, 0x66, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x4c, 0x69, 0x73, 0x74, 0x4c, 0x75, 0x63,
0x6b, 0x79, 0x47, 0x69, 0x66, 0x74, 0x50, 0x6f, 0x6f, 0x6c, 0x42, 0x61, 0x6c, 0x61, 0x6e, 0x63,
0x65, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x42, 0x30, 0x5a, 0x2e, 0x68, 0x79,
0x61, 0x70, 0x70, 0x2e, 0x6c, 0x6f, 0x63, 0x61, 0x6c, 0x2f, 0x61, 0x70, 0x69, 0x2f, 0x70, 0x72,
0x6f, 0x74, 0x6f, 0x2f, 0x6c, 0x75, 0x63, 0x6b, 0x79, 0x67, 0x69, 0x66, 0x74, 0x2f, 0x76, 0x31,
0x3b, 0x6c, 0x75, 0x63, 0x6b, 0x79, 0x67, 0x69, 0x66, 0x74, 0x76, 0x31, 0x62, 0x06, 0x70, 0x72,
0x6f, 0x74, 0x6f, 0x33,
0x44, 0x72, 0x61, 0x77, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x82, 0x01,
0x0a, 0x17, 0x47, 0x65, 0x74, 0x4c, 0x75, 0x63, 0x6b, 0x79, 0x47, 0x69, 0x66, 0x74, 0x44, 0x72,
0x61, 0x77, 0x53, 0x75, 0x6d, 0x6d, 0x61, 0x72, 0x79, 0x12, 0x32, 0x2e, 0x68, 0x79, 0x61, 0x70,
0x70, 0x2e, 0x6c, 0x75, 0x63, 0x6b, 0x79, 0x67, 0x69, 0x66, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x47,
0x65, 0x74, 0x4c, 0x75, 0x63, 0x6b, 0x79, 0x47, 0x69, 0x66, 0x74, 0x44, 0x72, 0x61, 0x77, 0x53,
0x75, 0x6d, 0x6d, 0x61, 0x72, 0x79, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x33, 0x2e,
0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x6c, 0x75, 0x63, 0x6b, 0x79, 0x67, 0x69, 0x66, 0x74, 0x2e,
0x76, 0x31, 0x2e, 0x47, 0x65, 0x74, 0x4c, 0x75, 0x63, 0x6b, 0x79, 0x47, 0x69, 0x66, 0x74, 0x44,
0x72, 0x61, 0x77, 0x53, 0x75, 0x6d, 0x6d, 0x61, 0x72, 0x79, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e,
0x73, 0x65, 0x12, 0x88, 0x01, 0x0a, 0x19, 0x4c, 0x69, 0x73, 0x74, 0x4c, 0x75, 0x63, 0x6b, 0x79,
0x47, 0x69, 0x66, 0x74, 0x50, 0x6f, 0x6f, 0x6c, 0x42, 0x61, 0x6c, 0x61, 0x6e, 0x63, 0x65, 0x73,
0x12, 0x34, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x6c, 0x75, 0x63, 0x6b, 0x79, 0x67, 0x69,
0x66, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x4c, 0x69, 0x73, 0x74, 0x4c, 0x75, 0x63, 0x6b, 0x79, 0x47,
0x69, 0x66, 0x74, 0x50, 0x6f, 0x6f, 0x6c, 0x42, 0x61, 0x6c, 0x61, 0x6e, 0x63, 0x65, 0x73, 0x52,
0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x35, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x6c,
0x75, 0x63, 0x6b, 0x79, 0x67, 0x69, 0x66, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x4c, 0x69, 0x73, 0x74,
0x4c, 0x75, 0x63, 0x6b, 0x79, 0x47, 0x69, 0x66, 0x74, 0x50, 0x6f, 0x6f, 0x6c, 0x42, 0x61, 0x6c,
0x61, 0x6e, 0x63, 0x65, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x42, 0x30, 0x5a,
0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x6c, 0x6f, 0x63, 0x61, 0x6c, 0x2f, 0x61, 0x70, 0x69,
0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2f, 0x6c, 0x75, 0x63, 0x6b, 0x79, 0x67, 0x69, 0x66, 0x74,
0x2f, 0x76, 0x31, 0x3b, 0x6c, 0x75, 0x63, 0x6b, 0x79, 0x67, 0x69, 0x66, 0x74, 0x76, 0x31, 0x62,
0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33,
}
var (
@ -2851,7 +2953,7 @@ func file_proto_luckygift_v1_luckygift_proto_rawDescGZIP() []byte {
return file_proto_luckygift_v1_luckygift_proto_rawDescData
}
var file_proto_luckygift_v1_luckygift_proto_msgTypes = make([]protoimpl.MessageInfo, 28)
var file_proto_luckygift_v1_luckygift_proto_msgTypes = make([]protoimpl.MessageInfo, 29)
var file_proto_luckygift_v1_luckygift_proto_goTypes = []any{
(*RequestMeta)(nil), // 0: hyapp.luckygift.v1.RequestMeta
(*LuckyGiftMeta)(nil), // 1: hyapp.luckygift.v1.LuckyGiftMeta
@ -2881,55 +2983,57 @@ var file_proto_luckygift_v1_luckygift_proto_goTypes = []any{
(*GetLuckyGiftDrawSummaryResponse)(nil), // 25: hyapp.luckygift.v1.GetLuckyGiftDrawSummaryResponse
(*ListLuckyGiftPoolBalancesRequest)(nil), // 26: hyapp.luckygift.v1.ListLuckyGiftPoolBalancesRequest
(*ListLuckyGiftPoolBalancesResponse)(nil), // 27: hyapp.luckygift.v1.ListLuckyGiftPoolBalancesResponse
(*LuckyGiftHit)(nil), // 28: hyapp.luckygift.v1.LuckyGiftHit
}
var file_proto_luckygift_v1_luckygift_proto_depIdxs = []int32{
0, // 0: hyapp.luckygift.v1.LuckyGiftMeta.meta:type_name -> hyapp.luckygift.v1.RequestMeta
2, // 1: hyapp.luckygift.v1.LuckyGiftRuleStage.tiers:type_name -> hyapp.luckygift.v1.LuckyGiftRuleTier
3, // 2: hyapp.luckygift.v1.LuckyGiftRuleConfig.stages:type_name -> hyapp.luckygift.v1.LuckyGiftRuleStage
0, // 3: hyapp.luckygift.v1.CheckLuckyGiftRequest.meta:type_name -> hyapp.luckygift.v1.RequestMeta
1, // 4: hyapp.luckygift.v1.ExecuteLuckyGiftDrawRequest.lucky_gift:type_name -> hyapp.luckygift.v1.LuckyGiftMeta
7, // 5: hyapp.luckygift.v1.ExecuteLuckyGiftDrawResponse.result:type_name -> hyapp.luckygift.v1.LuckyGiftDrawResult
1, // 6: hyapp.luckygift.v1.BatchExecuteLuckyGiftDrawRequest.lucky_gifts:type_name -> hyapp.luckygift.v1.LuckyGiftMeta
7, // 7: hyapp.luckygift.v1.BatchExecuteLuckyGiftDrawResponse.results:type_name -> hyapp.luckygift.v1.LuckyGiftDrawResult
0, // 8: hyapp.luckygift.v1.ExternalGiftDrawRequest.meta:type_name -> hyapp.luckygift.v1.RequestMeta
0, // 9: hyapp.luckygift.v1.GetLuckyGiftConfigRequest.meta:type_name -> hyapp.luckygift.v1.RequestMeta
4, // 10: hyapp.luckygift.v1.GetLuckyGiftConfigResponse.config:type_name -> hyapp.luckygift.v1.LuckyGiftRuleConfig
0, // 11: hyapp.luckygift.v1.UpsertLuckyGiftConfigRequest.meta:type_name -> hyapp.luckygift.v1.RequestMeta
4, // 12: hyapp.luckygift.v1.UpsertLuckyGiftConfigRequest.config:type_name -> hyapp.luckygift.v1.LuckyGiftRuleConfig
4, // 13: hyapp.luckygift.v1.UpsertLuckyGiftConfigResponse.config:type_name -> hyapp.luckygift.v1.LuckyGiftRuleConfig
0, // 14: hyapp.luckygift.v1.ListLuckyGiftConfigsRequest.meta:type_name -> hyapp.luckygift.v1.RequestMeta
4, // 15: hyapp.luckygift.v1.ListLuckyGiftConfigsResponse.configs:type_name -> hyapp.luckygift.v1.LuckyGiftRuleConfig
0, // 16: hyapp.luckygift.v1.ListLuckyGiftDrawsRequest.meta:type_name -> hyapp.luckygift.v1.RequestMeta
7, // 17: hyapp.luckygift.v1.ListLuckyGiftDrawsResponse.draws:type_name -> hyapp.luckygift.v1.LuckyGiftDrawResult
0, // 18: hyapp.luckygift.v1.GetLuckyGiftDrawSummaryRequest.meta:type_name -> hyapp.luckygift.v1.RequestMeta
22, // 19: hyapp.luckygift.v1.GetLuckyGiftDrawSummaryResponse.summary:type_name -> hyapp.luckygift.v1.LuckyGiftDrawSummary
0, // 20: hyapp.luckygift.v1.ListLuckyGiftPoolBalancesRequest.meta:type_name -> hyapp.luckygift.v1.RequestMeta
23, // 21: hyapp.luckygift.v1.ListLuckyGiftPoolBalancesResponse.pools:type_name -> hyapp.luckygift.v1.LuckyGiftPoolBalance
5, // 22: hyapp.luckygift.v1.LuckyGiftService.CheckLuckyGift:input_type -> hyapp.luckygift.v1.CheckLuckyGiftRequest
8, // 23: hyapp.luckygift.v1.LuckyGiftService.ExecuteLuckyGiftDraw:input_type -> hyapp.luckygift.v1.ExecuteLuckyGiftDrawRequest
10, // 24: hyapp.luckygift.v1.LuckyGiftService.BatchExecuteLuckyGiftDraw:input_type -> hyapp.luckygift.v1.BatchExecuteLuckyGiftDrawRequest
12, // 25: hyapp.luckygift.v1.LuckyGiftService.ExecuteExternalGiftDraw:input_type -> hyapp.luckygift.v1.ExternalGiftDrawRequest
14, // 26: hyapp.luckygift.v1.AdminLuckyGiftService.GetLuckyGiftConfig:input_type -> hyapp.luckygift.v1.GetLuckyGiftConfigRequest
16, // 27: hyapp.luckygift.v1.AdminLuckyGiftService.UpsertLuckyGiftConfig:input_type -> hyapp.luckygift.v1.UpsertLuckyGiftConfigRequest
18, // 28: hyapp.luckygift.v1.AdminLuckyGiftService.ListLuckyGiftConfigs:input_type -> hyapp.luckygift.v1.ListLuckyGiftConfigsRequest
20, // 29: hyapp.luckygift.v1.AdminLuckyGiftService.ListLuckyGiftDraws:input_type -> hyapp.luckygift.v1.ListLuckyGiftDrawsRequest
24, // 30: hyapp.luckygift.v1.AdminLuckyGiftService.GetLuckyGiftDrawSummary:input_type -> hyapp.luckygift.v1.GetLuckyGiftDrawSummaryRequest
26, // 31: hyapp.luckygift.v1.AdminLuckyGiftService.ListLuckyGiftPoolBalances:input_type -> hyapp.luckygift.v1.ListLuckyGiftPoolBalancesRequest
6, // 32: hyapp.luckygift.v1.LuckyGiftService.CheckLuckyGift:output_type -> hyapp.luckygift.v1.CheckLuckyGiftResponse
9, // 33: hyapp.luckygift.v1.LuckyGiftService.ExecuteLuckyGiftDraw:output_type -> hyapp.luckygift.v1.ExecuteLuckyGiftDrawResponse
11, // 34: hyapp.luckygift.v1.LuckyGiftService.BatchExecuteLuckyGiftDraw:output_type -> hyapp.luckygift.v1.BatchExecuteLuckyGiftDrawResponse
13, // 35: hyapp.luckygift.v1.LuckyGiftService.ExecuteExternalGiftDraw:output_type -> hyapp.luckygift.v1.ExternalGiftDrawResponse
15, // 36: hyapp.luckygift.v1.AdminLuckyGiftService.GetLuckyGiftConfig:output_type -> hyapp.luckygift.v1.GetLuckyGiftConfigResponse
17, // 37: hyapp.luckygift.v1.AdminLuckyGiftService.UpsertLuckyGiftConfig:output_type -> hyapp.luckygift.v1.UpsertLuckyGiftConfigResponse
19, // 38: hyapp.luckygift.v1.AdminLuckyGiftService.ListLuckyGiftConfigs:output_type -> hyapp.luckygift.v1.ListLuckyGiftConfigsResponse
21, // 39: hyapp.luckygift.v1.AdminLuckyGiftService.ListLuckyGiftDraws:output_type -> hyapp.luckygift.v1.ListLuckyGiftDrawsResponse
25, // 40: hyapp.luckygift.v1.AdminLuckyGiftService.GetLuckyGiftDrawSummary:output_type -> hyapp.luckygift.v1.GetLuckyGiftDrawSummaryResponse
27, // 41: hyapp.luckygift.v1.AdminLuckyGiftService.ListLuckyGiftPoolBalances:output_type -> hyapp.luckygift.v1.ListLuckyGiftPoolBalancesResponse
32, // [32:42] is the sub-list for method output_type
22, // [22:32] is the sub-list for method input_type
22, // [22:22] is the sub-list for extension type_name
22, // [22:22] is the sub-list for extension extendee
0, // [0:22] is the sub-list for field type_name
28, // 4: hyapp.luckygift.v1.LuckyGiftDrawResult.hits:type_name -> hyapp.luckygift.v1.LuckyGiftHit
1, // 5: hyapp.luckygift.v1.ExecuteLuckyGiftDrawRequest.lucky_gift:type_name -> hyapp.luckygift.v1.LuckyGiftMeta
7, // 6: hyapp.luckygift.v1.ExecuteLuckyGiftDrawResponse.result:type_name -> hyapp.luckygift.v1.LuckyGiftDrawResult
1, // 7: hyapp.luckygift.v1.BatchExecuteLuckyGiftDrawRequest.lucky_gifts:type_name -> hyapp.luckygift.v1.LuckyGiftMeta
7, // 8: hyapp.luckygift.v1.BatchExecuteLuckyGiftDrawResponse.results:type_name -> hyapp.luckygift.v1.LuckyGiftDrawResult
0, // 9: hyapp.luckygift.v1.ExternalGiftDrawRequest.meta:type_name -> hyapp.luckygift.v1.RequestMeta
0, // 10: hyapp.luckygift.v1.GetLuckyGiftConfigRequest.meta:type_name -> hyapp.luckygift.v1.RequestMeta
4, // 11: hyapp.luckygift.v1.GetLuckyGiftConfigResponse.config:type_name -> hyapp.luckygift.v1.LuckyGiftRuleConfig
0, // 12: hyapp.luckygift.v1.UpsertLuckyGiftConfigRequest.meta:type_name -> hyapp.luckygift.v1.RequestMeta
4, // 13: hyapp.luckygift.v1.UpsertLuckyGiftConfigRequest.config:type_name -> hyapp.luckygift.v1.LuckyGiftRuleConfig
4, // 14: hyapp.luckygift.v1.UpsertLuckyGiftConfigResponse.config:type_name -> hyapp.luckygift.v1.LuckyGiftRuleConfig
0, // 15: hyapp.luckygift.v1.ListLuckyGiftConfigsRequest.meta:type_name -> hyapp.luckygift.v1.RequestMeta
4, // 16: hyapp.luckygift.v1.ListLuckyGiftConfigsResponse.configs:type_name -> hyapp.luckygift.v1.LuckyGiftRuleConfig
0, // 17: hyapp.luckygift.v1.ListLuckyGiftDrawsRequest.meta:type_name -> hyapp.luckygift.v1.RequestMeta
7, // 18: hyapp.luckygift.v1.ListLuckyGiftDrawsResponse.draws:type_name -> hyapp.luckygift.v1.LuckyGiftDrawResult
0, // 19: hyapp.luckygift.v1.GetLuckyGiftDrawSummaryRequest.meta:type_name -> hyapp.luckygift.v1.RequestMeta
22, // 20: hyapp.luckygift.v1.GetLuckyGiftDrawSummaryResponse.summary:type_name -> hyapp.luckygift.v1.LuckyGiftDrawSummary
0, // 21: hyapp.luckygift.v1.ListLuckyGiftPoolBalancesRequest.meta:type_name -> hyapp.luckygift.v1.RequestMeta
23, // 22: hyapp.luckygift.v1.ListLuckyGiftPoolBalancesResponse.pools:type_name -> hyapp.luckygift.v1.LuckyGiftPoolBalance
5, // 23: hyapp.luckygift.v1.LuckyGiftService.CheckLuckyGift:input_type -> hyapp.luckygift.v1.CheckLuckyGiftRequest
8, // 24: hyapp.luckygift.v1.LuckyGiftService.ExecuteLuckyGiftDraw:input_type -> hyapp.luckygift.v1.ExecuteLuckyGiftDrawRequest
10, // 25: hyapp.luckygift.v1.LuckyGiftService.BatchExecuteLuckyGiftDraw:input_type -> hyapp.luckygift.v1.BatchExecuteLuckyGiftDrawRequest
12, // 26: hyapp.luckygift.v1.LuckyGiftService.ExecuteExternalGiftDraw:input_type -> hyapp.luckygift.v1.ExternalGiftDrawRequest
14, // 27: hyapp.luckygift.v1.AdminLuckyGiftService.GetLuckyGiftConfig:input_type -> hyapp.luckygift.v1.GetLuckyGiftConfigRequest
16, // 28: hyapp.luckygift.v1.AdminLuckyGiftService.UpsertLuckyGiftConfig:input_type -> hyapp.luckygift.v1.UpsertLuckyGiftConfigRequest
18, // 29: hyapp.luckygift.v1.AdminLuckyGiftService.ListLuckyGiftConfigs:input_type -> hyapp.luckygift.v1.ListLuckyGiftConfigsRequest
20, // 30: hyapp.luckygift.v1.AdminLuckyGiftService.ListLuckyGiftDraws:input_type -> hyapp.luckygift.v1.ListLuckyGiftDrawsRequest
24, // 31: hyapp.luckygift.v1.AdminLuckyGiftService.GetLuckyGiftDrawSummary:input_type -> hyapp.luckygift.v1.GetLuckyGiftDrawSummaryRequest
26, // 32: hyapp.luckygift.v1.AdminLuckyGiftService.ListLuckyGiftPoolBalances:input_type -> hyapp.luckygift.v1.ListLuckyGiftPoolBalancesRequest
6, // 33: hyapp.luckygift.v1.LuckyGiftService.CheckLuckyGift:output_type -> hyapp.luckygift.v1.CheckLuckyGiftResponse
9, // 34: hyapp.luckygift.v1.LuckyGiftService.ExecuteLuckyGiftDraw:output_type -> hyapp.luckygift.v1.ExecuteLuckyGiftDrawResponse
11, // 35: hyapp.luckygift.v1.LuckyGiftService.BatchExecuteLuckyGiftDraw:output_type -> hyapp.luckygift.v1.BatchExecuteLuckyGiftDrawResponse
13, // 36: hyapp.luckygift.v1.LuckyGiftService.ExecuteExternalGiftDraw:output_type -> hyapp.luckygift.v1.ExternalGiftDrawResponse
15, // 37: hyapp.luckygift.v1.AdminLuckyGiftService.GetLuckyGiftConfig:output_type -> hyapp.luckygift.v1.GetLuckyGiftConfigResponse
17, // 38: hyapp.luckygift.v1.AdminLuckyGiftService.UpsertLuckyGiftConfig:output_type -> hyapp.luckygift.v1.UpsertLuckyGiftConfigResponse
19, // 39: hyapp.luckygift.v1.AdminLuckyGiftService.ListLuckyGiftConfigs:output_type -> hyapp.luckygift.v1.ListLuckyGiftConfigsResponse
21, // 40: hyapp.luckygift.v1.AdminLuckyGiftService.ListLuckyGiftDraws:output_type -> hyapp.luckygift.v1.ListLuckyGiftDrawsResponse
25, // 41: hyapp.luckygift.v1.AdminLuckyGiftService.GetLuckyGiftDrawSummary:output_type -> hyapp.luckygift.v1.GetLuckyGiftDrawSummaryResponse
27, // 42: hyapp.luckygift.v1.AdminLuckyGiftService.ListLuckyGiftPoolBalances:output_type -> hyapp.luckygift.v1.ListLuckyGiftPoolBalancesResponse
33, // [33:43] is the sub-list for method output_type
23, // [23:33] is the sub-list for method input_type
23, // [23:23] is the sub-list for extension type_name
23, // [23:23] is the sub-list for extension extendee
0, // [0:23] is the sub-list for field type_name
}
func init() { file_proto_luckygift_v1_luckygift_proto_init() }
@ -2943,7 +3047,7 @@ func file_proto_luckygift_v1_luckygift_proto_init() {
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
RawDescriptor: file_proto_luckygift_v1_luckygift_proto_rawDesc,
NumEnums: 0,
NumMessages: 28,
NumMessages: 29,
NumExtensions: 0,
NumServices: 2,
},

View File

@ -108,6 +108,7 @@ message LuckyGiftDrawResult {
int64 user_id = 21;
string external_user_id = 22;
string app_code = 23;
repeated LuckyGiftHit hits = 24;
}
message ExecuteLuckyGiftDrawRequest {
@ -267,3 +268,13 @@ service AdminLuckyGiftService {
rpc GetLuckyGiftDrawSummary(GetLuckyGiftDrawSummaryRequest) returns (GetLuckyGiftDrawSummaryResponse);
rpc ListLuckyGiftPoolBalances(ListLuckyGiftPoolBalancesRequest) returns (ListLuckyGiftPoolBalancesResponse);
}
// LuckyGiftHit
// protobuf message index
message LuckyGiftHit {
int32 gift_index = 1;
string draw_id = 2;
string selected_tier_id = 3;
int64 multiplier_ppm = 4;
int64 reward_coins = 5;
}

File diff suppressed because it is too large Load Diff

View File

@ -110,6 +110,8 @@ message LuckyGiftDrawResult {
int64 coin_balance_after = 17;
// target_user_id
int64 target_user_id = 18;
// hits gift_count
repeated SendGiftLuckyHit hits = 19;
}
// RoomRocketRewardItem
@ -1153,6 +1155,9 @@ message SendGiftRequest {
repeated SendGiftDisplayProfile target_display_profiles = 17;
// display_mode=batch batch-send HTTP IM send
string display_mode = 18;
// combo_session_id + batch_seq Flutter command_id
string combo_session_id = 19;
int64 batch_seq = 20;
}
// SendGiftResponse
@ -1173,6 +1178,8 @@ message SendGiftResponse {
int64 gift_income_balance_after = 10;
// batch_display batch-send send
SendGiftBatchDisplay batch_display = 11;
// v2 Flutter V1
SendGiftResultV2 v2 = 12;
}
// CheckSpeakPermissionRequest IM gateway
@ -1538,3 +1545,41 @@ service RoomQueryService {
rpc ListRoomOnlineUsers(ListRoomOnlineUsersRequest) returns (ListRoomOnlineUsersResponse);
rpc ListRoomBannedUsers(ListRoomBannedUsersRequest) returns (ListRoomBannedUsersResponse);
}
// SendGiftLuckyHit V2
// protobuf message index
message SendGiftLuckyHit {
int32 gift_index = 1;
string draw_id = 2;
int64 target_user_id = 3;
string selected_tier_id = 4;
int64 multiplier_ppm = 5;
int64 reward_coins = 6;
}
// SendGiftResultV2
// SendGiftResponse V1 V2
//
message SendGiftResultV2 {
int32 api_version = 1;
string command_id = 2;
string combo_session_id = 3;
int64 batch_seq = 4;
bool idempotent_replay = 5;
int64 committed_room_version = 6;
string billing_receipt_id = 7;
int64 coin_balance_after = 8;
int64 gift_income_balance_after = 9;
int32 settled_gift_count = 10;
repeated int64 settled_target_user_ids = 11;
// balance_version sender wallet
int64 balance_version = 12;
LuckyGiftDrawResult lucky_gift = 13;
repeated LuckyGiftDrawResult lucky_gifts = 14;
repeated SendGiftLuckyHit lucky_hits = 15;
SendGiftBatchDisplay batch_display = 16;
int64 room_heat = 17;
repeated RankItem gift_rank = 18;
// operation_status committed V2
string operation_status = 19;
}

File diff suppressed because it is too large Load Diff

View File

@ -19,6 +19,9 @@ message RequestMeta {
string platform = 11;
string language = 12;
string timezone = 13;
// app_version/build_number RPC login_audit
string app_version = 14;
string build_number = 15;
}
// App user-service App

File diff suppressed because it is too large Load Diff

View File

@ -90,6 +90,8 @@ message DebitGiftResponse {
string host_point_policy_instance_code = 25;
string host_point_template_code = 26;
string host_point_template_version = 27;
// balance_version sender command_id
int64 balance_version = 28;
}
// DebitGiftTarget

View File

@ -1,6 +1,6 @@
# Outbox 统一治理说明
更新时间2026-06-01
更新时间2026-07-11
## 背景
@ -54,6 +54,63 @@
- `delivering/running expired lock`
- claim 查询全部显式带 `app_code`,并匹配对应索引。
- delivered/done 历史 outbox 通过归档脚本搬到 archive 表。
- wallet/room topic 使用可灰度的 typed Tagconsumer 先部署
`legacy || relevant event_type`producer 再从 `legacy` 切到 `event_type`
- 同步 MQ publisher 每次只 claim 一条 outbox`batch_size` 表示单轮最多处理
条数,不再表示一次提前锁住的行数,避免批尾 lease 在真正发送前过期。
## Typed Tag 无损切换
Typed Tag 只降低无关 consumer group 的投递、反序列化和 handler 调用量,不会
减少 Broker ingress 或存储。礼物消息总量必须在写入 owner outbox 前通过送礼微批
收敛;多人礼物仍按 target 保留各自 `WalletGiftDebited``RoomGiftSent` 事实,且
每条事实携带聚合后的 `gift_count`
切换顺序必须严格执行:
1. 先滚动发布所有 wallet consumer确认订阅表达式包含
`wallet_outbox_event || <相关 event_type>`,且同一 group 所有副本表达式一致。
2. 确认旧 consumer 实例归零、消费错误率稳定,再仅把 wallet-service
`rocketmq.wallet_event_tag_mode``legacy` 改为 `event_type`
3. 保留 consumer 的 legacy 分支直到切换前积压、retry 和计划内 replay 窗口全部
排空;不能在 producer 切换同一批发布中移除 legacy。
4. wallet 稳定后,以同样顺序先发布 activity/statistics/notice/user 等 room
consumer再把 room-service `rocketmq.room_event_tag_mode``legacy` 改为
`event_type`
5. 如需回滚 producer只改回 `legacy`;兼容 consumer 无需回滚。只有确认 Broker
已无 legacy 消息且不再重放旧 outbox 后,才可以另行删除 legacy selector。
推荐验证:
```text
Phase A: producer tag=legacy每个 consumer group 仍能消费原有事实
Phase B: gift typed tag 只进入需要 gift 的 group首充/累充/邀请等 group lag 不再随礼物增长
Rollback: producer 改回 legacy 后所有兼容 consumer 继续消费,无消息不可见窗口
```
Topic 拆分不是降低消息总量的手段。完成微批和 typed Tag 后,只有在礼物流量仍
持续挤压非礼物 SLO或确实需要独立 retention/retry/ACL/顺序策略时才拆高频 Topic
流量占比只能作为评审信号,不能把固定百分比当作行业标准。
### 历史礼物消费修复
新代码只阻止新增的 `WalletBalanceChanged(direct_gift_debit)``RoomGiftSent`
双计;已经落入 `stat_app_day_country.consumed_coin` 的历史窗口不能直接拍脑袋减值。
上线后使用现有 `replay-stat-tz` 从四个 owner outbox 重建受影响窗口,并分别执行
UTC 与 Asia/Shanghai 两套口径。先不带 `--apply` 核对 source_rows再在停止该
窗口实时写入或只选择已完成自然日时执行 apply
```bash
go run ./services/statistics-service/cmd/replay-stat-tz \
--app lalu --timezone UTC --start-day 2026-07-01 --end-day 2026-07-10
go run ./services/statistics-service/cmd/replay-stat-tz \
--app lalu --timezone Asia/Shanghai --start-day 2026-07-01 --end-day 2026-07-10
```
确认四类 owner source_rows 非零且窗口正确后,为同一命令补充 `--apply` 和运行时
DSN 环境变量。每个 App 独立执行;完成后校验 `consumed_coin` 中礼物部分等于
`RoomGiftSent.coin_spent`,钱包礼物扣款只继续影响 `coin_total`
## 修复掉的问题

View File

@ -96,6 +96,8 @@ rocketmq:
namespace: ""
send_timeout: "3s"
retry: 2
# 安全默认值;全部 room consumer 完成 legacy||typed 兼容订阅后才切 event_type。
room_event_tag_mode: "legacy"
room_outbox:
enabled: false
topic: "hyapp_room_outbox"
@ -127,12 +129,13 @@ outbox_worker:
| 字段 | 规则 |
| --- | --- |
| `rocketmq.enabled` | 控制 RocketMQ client 是否启用;本地默认关闭,线上示例开启 |
| `rocketmq.room_event_tag_mode` | `legacy` 是无损迁移默认值;`event_type` 只在所有 consumer 已双订阅后显式开启 |
| `rocketmq.room_outbox.enabled` | 控制 room outbox 是否可发布到 MQ`publish_mode=mq/dual` 时必须开启 |
| `rocketmq.room_outbox.tencent_im_consumer_enabled` | 控制 room-service 是否作为 IM bridge consumer 消费 `hyapp_room_outbox` |
| `rocketmq.rocket_launch.enabled` | 控制火箭点火后是否发布和消费延迟发射唤醒消息 |
| `outbox_worker.publish_mode` | 本地默认 `direct`,线上建议 `mq`,迁移期可用 `dual` |
| `poll_interval` | worker 空轮询间隔,必须大于 0避免 busy loop |
| `batch_size` | 每轮最大抢占条数,必须大于 0避免全表扫描 |
| `batch_size` | 每轮最多处理条数;同步 publisher 逐条 claim避免批尾尚未发送 lease 就已过期 |
| `publish_timeout` | 单条 outbox 发布 deadline避免外部系统拖死 worker |
| `max_retry_count` | 达到次数后标记 `failed`,防止 poison event 永久刷日志 |

View File

@ -0,0 +1,94 @@
# 送礼连击 V2 协议
## 边界
V2 只改变客户端封批、可靠重试和结算结果重放,不改变房间状态 owner
- Flutter 立即播放连击反馈,第一批立即请求,后续点击按远程窗口聚合。
- HTTP 仍是账务入口;`room-service` 的 Room Cell 串行提交房间状态。
- `wallet-service` 负责扣费,`lucky-gift-service` 负责逐份独立抽奖,`room-service` 只编排持久化 Saga。
- V1 路由和顶层响应字段只为已发布的旧客户端保留;当前 Flutter 固定调用 V2不做 V1 降级。
## HTTP 契约
新客户端调用:
```text
POST /api/v2/rooms/gift/send
```
请求沿用 V1 字段,并要求:
```json
{
"room_id": "room-id",
"command_id": "stable-combo-session_b1",
"combo_session_id": "stable-combo-session",
"batch_seq": 1,
"target_type": "user",
"target_user_ids": [1001, 1002],
"gift_id": "gift-id",
"gift_count": 20,
"pool_id": "optional-lucky-pool"
}
```
`command_id` 是唯一财务幂等键。超时、429 和临时 5xx 必须复用同一请求体与同一 `command_id``combo_session_id + batch_seq` 只用于客户端批次顺序和审计。当前 Flutter 不在 404 或旧响应下改调 V1发布前必须先保证 V2 路由可用。
响应继续返回 V1 顶层的 `billing_receipt_id``coin_balance_after``lucky_gift(s)``batch_display` 等字段,同时增加 `v2`
```json
{
"billing_receipt_id": "legacy-compatible-receipt",
"coin_balance_after": 9000,
"v2": {
"api_version": 2,
"command_id": "stable-combo-session_b1",
"combo_session_id": "stable-combo-session",
"batch_seq": 1,
"idempotent_replay": false,
"committed_room_version": 123,
"billing_receipt_id": "legacy-compatible-receipt",
"coin_balance_after": 9000,
"balance_version": 456,
"settled_gift_count": 20,
"settled_target_user_ids": [1001, 1002],
"lucky_hits": [],
"operation_status": "committed"
}
}
```
相同 `command_id` 的重试返回首次提交的同一账务回执、余额版本、实际目标、幸运结果和批次展示,仅把 `idempotent_replay` 置为 `true`。历史 V1 command log 没有 typed 结果时保持原有 snapshot-only 行为,不伪造结算结果。
## 客户端封批规则
礼物面板响应的 `combo_config` 控制客户端策略:
- `enabled`:紧急关闭微批;关闭后仍调用 V2只是不再聚合连续点击。
- `api_version`:服务端契约版本标记,当前 Flutter 只接受 V2不用它选择 V1 入口。
- `window_ms`:后续点击固定封批窗口,默认 300ms。
- `max_gift_count_per_batch`:单目标数量上限,最大 999。
- `max_gift_units_per_batch``gift_count * 去重收礼人数` 上限,最大 5000。
- `max_in_flight`:最多 2 个 HTTP 批次在途。
- `max_pending_batches`:本地有界队列上限。
- `retry_max_attempts``retry_base_delay_ms`可靠重试上限和指数退避基数429 优先遵守 `Retry-After`
- `rollout_basis_points`:按 `app_code + user_id` 稳定分桶0 到 10000只灰度微批策略不改变 V2 HTTP 入口。
后台通过以下 app-scoped 接口读取和全量替换配置:
```text
GET /admin/app-config/gift-combo
PUT /admin/app-config/gift-combo
```
服务端在 gateway、room、wallet、lucky 四层都强制校验 `gift_count <= 999`、总 gift units `<= 5000` 和幂等键长度。远程配置只能收紧客户端行为,不能提高 owner 上限。
## 上线顺序
1. 先执行 `services/room-service/deploy/mysql/migrations/002_gift_operation_v2.sql`,再滚动发布 protobuf、wallet、lucky、room、gateway确认 V2 可用后发布当前 Flutter已发布的旧 Flutter 继续走服务端 V1。
2. 首次上线把 `rollout_basis_points` 设为 0验证 V2 单批发送、重复 `command_id` 重放和 Gift Saga 恢复指标;该值不会让当前 Flutter 回退 V1。
3. 按 App 从小流量提高微批灰度;异常时把 `enabled=false` 或灰度归零,客户端仍使用 V2 单批发送。
4. MQ 先保持 `wallet_event_tag_mode=legacy``room_event_tag_mode=legacy`。确认所有兼容 consumer 已上线并排空存量后,再分别切为 `event_type`;不要在同一批发布中同时切 producer 和删除 legacy selector。
数据库迁移、服务发布、Flutter 微批灰度和 MQ Tag 切换是四个独立开关。任何一步都不能用“客户端已限流”替代 owner 幂等、Saga 恢复或 Broker 侧过滤。

32
pkg/giftlimits/limits.go Normal file
View File

@ -0,0 +1,32 @@
// Package giftlimits defines the owner-enforced ceiling for one gift command.
// Runtime rate limits may be stricter, but no transport or service is allowed to raise these values
// independently because gift_count fan-out reaches wallet, lucky draw, Room Cell and outbox storage.
package giftlimits
import "strings"
const (
MaxCommandIDBytes = 128
MaxGiftCount = int64(999)
MaxTotalGiftUnits = int64(5_000)
)
// ValidCommandID bounds the financial idempotency key before it reaches Redis/MySQL key material.
func ValidCommandID(commandID string) bool {
commandID = strings.TrimSpace(commandID)
return commandID != "" && len(commandID) <= MaxCommandIDBytes
}
// WorkUnits calculates gift_count*unique_target_count without overflow and applies the cross-service
// hard ceiling. Callers must pass a deduplicated target count; wallet additionally rejects duplicates.
func WorkUnits(giftCount int64, uniqueTargetCount int) (int64, bool) {
if giftCount <= 0 || giftCount > MaxGiftCount || uniqueTargetCount <= 0 {
return 0, false
}
targetCount := int64(uniqueTargetCount)
if targetCount > MaxTotalGiftUnits/giftCount {
return 0, false
}
units := giftCount * targetCount
return units, units <= MaxTotalGiftUnits
}

View File

@ -0,0 +1,29 @@
package giftlimits
import (
"strings"
"testing"
)
func TestWorkUnitsAppliesCountAndFanoutCeilings(t *testing.T) {
if units, ok := WorkUnits(999, 5); !ok || units != 4_995 {
t.Fatalf("valid maximum-near workload mismatch: units=%d ok=%t", units, ok)
}
for _, input := range []struct {
count int64
targets int
}{{0, 1}, {1_000, 1}, {999, 6}, {1, 0}} {
if units, ok := WorkUnits(input.count, input.targets); ok || units != 0 {
t.Fatalf("invalid workload accepted: %+v units=%d", input, units)
}
}
}
func TestValidCommandIDUsesTrimmedNonEmptyByteBound(t *testing.T) {
if !ValidCommandID(" combo-1:42 ") {
t.Fatal("normal stable combo command id was rejected")
}
if ValidCommandID(" ") || ValidCommandID(strings.Repeat("x", MaxCommandIDBytes+1)) {
t.Fatal("empty or oversized command id was accepted")
}
}

View File

@ -4,6 +4,9 @@ package roommq
import (
"encoding/json"
"errors"
"fmt"
"sort"
"strings"
"google.golang.org/protobuf/proto"
roomeventsv1 "hyapp.local/api/proto/events/room/v1"
@ -13,10 +16,83 @@ const (
MessageTypeRoomOutboxEvent = "room_outbox_event"
MessageTypeRoomRocketLaunchDue = "room_rocket_launch_due"
TagRoomOutboxEvent = "room_outbox_event"
// TagRoomOutboxEvent 是 typed tag 上线前所有房间事实共用的 legacy Tag。
// 迁移窗口内 consumer 必须订阅 legacy || relevant typed tags等所有旧消息
// 和重试消息排空后才允许移除 legacy不能直接切换造成存量事实不可见。
TagRoomOutboxEvent = "room_outbox_event"
// 以下事件类型是当前 room topic 下游实际使用的稳定事实名。producer 的
// typed 模式直接使用 envelope.event_typeconsumer 复用这些常量拼 selector
// 避免 producer/consumer 两侧手写字符串漂移后静默漏消费。
EventTypeRoomGiftSent = "RoomGiftSent"
EventTypeRoomUserJoined = "RoomUserJoined"
EventTypeRoomUserLeft = "RoomUserLeft"
EventTypeRoomUserKicked = "RoomUserKicked"
EventTypeRoomMicChanged = "RoomMicChanged"
EventTypeRoomRocketIgnited = "RoomRocketIgnited"
EventTypeRoomRocketRewardGranted = "RoomRocketRewardGranted"
EventTypeRoomPasswordChanged = "RoomPasswordChanged"
TagRoomRocketLaunchDue = "room_rocket_launch_due"
)
// EventTagMode 控制 room outbox 的 Broker Tag它只改变 MQ 路由,不改变消息体协议。
type EventTagMode string
const (
// EventTagModeLegacy 是安全默认值,保证缺少新配置的旧环境继续发布统一 Tag。
EventTagModeLegacy EventTagMode = "legacy"
// EventTagModeEventType 只应在所有 consumer 完成兼容订阅滚动后显式启用。
EventTagModeEventType EventTagMode = "event_type"
)
// EventTypeTag 把一个受控的 room event_type 转成 RocketMQ Tag。事件类型来自
// owner outbox 而不是用户输入,但仍需拒绝表达式操作符和标点,避免坏数据扩大
// 订阅范围或发布成 consumer 无法安全表达的 Tag。
func EventTypeTag(eventType string) (string, error) {
tag := strings.TrimSpace(eventType)
if tag == "" {
return "", errors.New("room event_type tag is required")
}
if tag == TagRoomOutboxEvent {
return "", errors.New("room event_type cannot reuse the legacy room tag")
}
for _, char := range tag {
if (char >= 'a' && char <= 'z') ||
(char >= 'A' && char <= 'Z') ||
(char >= '0' && char <= '9') ||
char == '_' || char == '-' {
continue
}
return "", fmt.Errorf("room event_type %q cannot be used as a RocketMQ tag", eventType)
}
return tag, nil
}
// LegacyCompatibleTagExpression 构造无损迁移 selectorlegacy 分支消费切换前
// 已存在 Broker 的消息typed 分支只接收当前 consumer 真正处理的事件。排序和
// 去重保证同一 consumer group 的所有副本注册完全相同的订阅元数据。
func LegacyCompatibleTagExpression(eventTypes ...string) (string, error) {
typedTags := make([]string, 0, len(eventTypes))
seen := make(map[string]struct{}, len(eventTypes))
for _, eventType := range eventTypes {
tag, err := EventTypeTag(eventType)
if err != nil {
return "", err
}
if _, exists := seen[tag]; exists {
continue
}
seen[tag] = struct{}{}
typedTags = append(typedTags, tag)
}
if len(typedTags) == 0 {
return "", errors.New("at least one typed room event tag is required")
}
sort.Strings(typedTags)
return TagRoomOutboxEvent + " || " + strings.Join(typedTags, " || "), nil
}
// RoomOutboxMessage is the MQ representation of one durable room_outbox fact.
type RoomOutboxMessage struct {
MessageType string `json:"message_type"`

View File

@ -1,6 +1,7 @@
package roommq
import (
"strings"
"testing"
"google.golang.org/protobuf/proto"
@ -34,6 +35,59 @@ func TestRoomOutboxMessageRoundTrip(t *testing.T) {
}
}
func TestRoomEventTypeTagUsesExactTrimmedEventType(t *testing.T) {
tag, err := EventTypeTag(" RoomGiftSent ")
if err != nil {
t.Fatalf("EventTypeTag failed: %v", err)
}
if tag != EventTypeRoomGiftSent {
t.Fatalf("event tag = %q, want %q", tag, EventTypeRoomGiftSent)
}
for _, invalid := range []string{"", TagRoomOutboxEvent, "Room GiftSent", "RoomGiftSent || *", "Room.GiftSent"} {
if _, err := EventTypeTag(invalid); err == nil {
t.Fatalf("EventTypeTag(%q) unexpectedly succeeded", invalid)
}
}
}
func TestRoomLegacyCompatibleTagExpressionIsDeterministicAndDeduplicated(t *testing.T) {
first, err := LegacyCompatibleTagExpression(
EventTypeRoomUserJoined,
EventTypeRoomGiftSent,
EventTypeRoomUserJoined,
)
if err != nil {
t.Fatalf("LegacyCompatibleTagExpression failed: %v", err)
}
second, err := LegacyCompatibleTagExpression(EventTypeRoomGiftSent, EventTypeRoomUserJoined)
if err != nil {
t.Fatalf("LegacyCompatibleTagExpression failed: %v", err)
}
if first != second {
t.Fatalf("expressions differ by input order: first=%q second=%q", first, second)
}
want := TagRoomOutboxEvent + " || " + EventTypeRoomGiftSent + " || " + EventTypeRoomUserJoined
if first != want {
t.Fatalf("expression = %q, want %q", first, want)
}
if strings.Count(first, EventTypeRoomUserJoined) != 1 {
t.Fatalf("typed tag was not deduplicated: %q", first)
}
}
func TestRoomLegacyCompatibleTagExpressionRejectsUnsafeOrMissingTypedTags(t *testing.T) {
for _, eventTypes := range [][]string{
nil,
{TagRoomOutboxEvent},
{"RoomGiftSent || *"},
} {
if _, err := LegacyCompatibleTagExpression(eventTypes...); err == nil {
t.Fatalf("LegacyCompatibleTagExpression(%v) unexpectedly succeeded", eventTypes)
}
}
}
func TestRoomRocketLaunchDueRoundTrip(t *testing.T) {
encoded, err := EncodeRoomRocketLaunchDueMessage(RoomRocketLaunchDueMessage{
AppCode: "default",

View File

@ -28,6 +28,15 @@ SQL_FILES=(
"${LOCAL_INITDB_DIR}/999_local_grants.sql"
)
# initdb only creates missing tables; a reused local MySQL volume therefore
# needs incremental migrations as well. Keep the owned user-service migrations
# in this local bootstrap path so authentication schema changes (for example
# login_audit client-version fields) cannot leave a locally logged-in client
# receiving a misleading 401 during token refresh.
USER_MIGRATION_FILES=(
"services/user-service/deploy/mysql/migrations/012_login_audit_client_version.sql"
)
# Keep MySQL as the only required dependency for this script. Business services
# are started after schema/grants are known to match the current repo.
"${SCRIPT_DIR}/prepare-local-mysql-initdb.sh"
@ -61,3 +70,13 @@ for sql_file in "${SQL_FILES[@]}"; do
printf 'applying mysql init file: %s\n' "${sql_file}"
docker compose exec -T mysql mysql --default-character-set=utf8mb4 -h 127.0.0.1 -uroot -p"${MYSQL_ROOT_PASSWORD}" < "${sql_file}"
done
for sql_file in "${USER_MIGRATION_FILES[@]}"; do
if [[ ! -f "${sql_file}" ]]; then
printf 'skipping mysql migration because it is absent or not a regular file: %s\n' "${sql_file}"
continue
fi
printf 'applying mysql migration: %s\n' "${sql_file}"
docker compose exec -T mysql mysql --default-character-set=utf8mb4 -h 127.0.0.1 -uroot -p"${MYSQL_ROOT_PASSWORD}" < "${sql_file}"
done

View File

@ -338,13 +338,32 @@ func main() {
financeapplicationmodule.WithWalletClient(walletclient.NewGRPC(walletConn)),
)
// 社交 BI 与大屏共享同一个 dashboard 服务实例,保证外部源路由和统计口径一致。
dashboardService := dashboardmodule.NewService(store, cfg, userclient.NewGRPC(userConn), dashboardmodule.WithExternalDashboardSources(dashboardExternalSources...))
dashboardService := dashboardmodule.NewService(
store,
cfg,
userclient.NewGRPC(userConn),
// 用户画像从 userDB 读取已完成真实用户的总量、性别和最近一次成功登录版本;
// 当天新增与日活继续走 statistics-service避免重算事件去重口径。
dashboardmodule.WithUserDB(userDB),
dashboardmodule.WithExternalDashboardSources(dashboardExternalSources...),
)
appRegistryService := appregistrymodule.NewService(userDB)
// Finance 与 Social BI 必须复用同一 Handler 实例,确保普通充值、已核验币商订单和 H5 币商充值完全同口径。
paymentHandler := paymentmodule.New(
walletclient.NewGRPC(walletConn),
userDB,
walletDB,
store,
auditHandler,
paymentmodule.WithMoneyRegionSources(moneyRegionSources...),
paymentmodule.WithRechargeBillSources(financeBillSources...),
)
databiService := databimodule.NewService(
store,
dashboardService,
appRegistryService,
userclient.NewGRPC(userConn),
databimodule.WithFinanceRechargeOverviewSource(paymentHandler),
databimodule.WithLegacyApps(databiLegacyApps(cfg.DashboardExternalSources)...),
databimodule.WithLegacyRegionCatalogs(databiLegacyRegionCatalogs(moneyRegionSources)...),
)
@ -389,7 +408,7 @@ func main() {
LevelConfig: levelconfigmodule.New(activityclient.NewGRPC(activityConn), walletclient.NewGRPC(walletConn), auditHandler),
Menu: menumodule.New(store, auditHandler),
OpsCenter: opscentermodule.New(appRegistryService, luckyGiftHandler),
Payment: paymentmodule.New(walletclient.NewGRPC(walletConn), userDB, walletDB, store, auditHandler, paymentmodule.WithMoneyRegionSources(moneyRegionSources...), paymentmodule.WithRechargeBillSources(financeBillSources...)),
Payment: paymentHandler,
PolicyConfig: policyconfigmodule.New(sqlDB, walletDB, userDB, activityclient.NewGRPC(activityConn), auditHandler),
PrettyID: prettyidmodule.New(userclient.NewGRPC(userConn), auditHandler),
RBAC: rbacmodule.New(store, auditHandler),

View File

@ -0,0 +1,224 @@
package appconfig
import (
"encoding/json"
"errors"
"fmt"
"strings"
"hyapp-admin-server/internal/appctx"
"hyapp-admin-server/internal/model"
"hyapp-admin-server/internal/modules/shared"
"hyapp-admin-server/internal/response"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
const (
giftComboConfigGroup = "room-gift-combo"
giftComboConfigKey = "default"
)
// GiftComboConfigRequest is intentionally a full replacement document. Operators can inspect the
// current normalized response before updating it, and enabled=false is therefore an unambiguous
// one-click kill switch rather than an omitted boolean accidentally inheriting true.
type GiftComboConfigRequest struct {
Enabled bool `json:"enabled"`
APIVersion int32 `json:"apiVersion"`
WindowMS int32 `json:"windowMs"`
MaxGiftCountPerBatch int32 `json:"maxGiftCountPerBatch"`
MaxGiftUnitsPerBatch int32 `json:"maxGiftUnitsPerBatch"`
MaxInFlight int32 `json:"maxInFlight"`
MaxPendingBatches int32 `json:"maxPendingBatches"`
RetryMaxAttempts int32 `json:"retryMaxAttempts"`
RetryBaseDelayMS int32 `json:"retryBaseDelayMs"`
RolloutBasisPoints int32 `json:"rolloutBasisPoints"`
}
type storedGiftComboConfig struct {
Enabled bool `json:"enabled"`
APIVersion int32 `json:"api_version"`
WindowMS int32 `json:"window_ms"`
MaxGiftCountPerBatch int32 `json:"max_gift_count_per_batch"`
MaxGiftUnitsPerBatch int32 `json:"max_gift_units_per_batch"`
MaxInFlight int32 `json:"max_in_flight"`
MaxPendingBatches int32 `json:"max_pending_batches"`
RetryMaxAttempts int32 `json:"retry_max_attempts"`
RetryBaseDelayMS int32 `json:"retry_base_delay_ms"`
RolloutBasisPoints int32 `json:"rollout_basis_points"`
}
type GiftComboConfigDTO struct {
GiftComboConfigRequest
AppCode string `json:"appCode"`
UpdatedAtMS int64 `json:"updatedAtMs"`
}
func defaultStoredGiftComboConfig() storedGiftComboConfig {
return storedGiftComboConfig{
Enabled: true,
APIVersion: 2,
WindowMS: 300,
MaxGiftCountPerBatch: 999,
MaxGiftUnitsPerBatch: 5_000,
MaxInFlight: 2,
MaxPendingBatches: 20,
RetryMaxAttempts: 5,
RetryBaseDelayMS: 250,
// 新 App 没有配置行时只展示安全的 0% 灰度,必须由管理员显式放量。
RolloutBasisPoints: 0,
}
}
func normalizeStoredGiftComboConfig(config storedGiftComboConfig) (storedGiftComboConfig, error) {
if config.APIVersion == 0 {
config.APIVersion = 2
}
if config.APIVersion != 2 {
// 后台只允许调整微批窗口、背压和灰度;接口版本不是运营开关,避免把当前 Flutter
// 从具备完整结算结果重放的 V2 静默切回 V1。
return storedGiftComboConfig{}, errors.New("apiVersion must be 2")
}
if config.WindowMS < 100 || config.WindowMS > 1_000 {
return storedGiftComboConfig{}, errors.New("windowMs must be between 100 and 1000")
}
if config.MaxGiftCountPerBatch < 1 || config.MaxGiftCountPerBatch > 999 {
return storedGiftComboConfig{}, errors.New("maxGiftCountPerBatch must be between 1 and 999")
}
if config.MaxGiftUnitsPerBatch < 1 || config.MaxGiftUnitsPerBatch > 5_000 {
return storedGiftComboConfig{}, errors.New("maxGiftUnitsPerBatch must be between 1 and 5000")
}
if config.MaxInFlight < 1 || config.MaxInFlight > 2 {
return storedGiftComboConfig{}, errors.New("maxInFlight must be between 1 and 2")
}
if config.MaxPendingBatches < 1 || config.MaxPendingBatches > 50 {
return storedGiftComboConfig{}, errors.New("maxPendingBatches must be between 1 and 50")
}
if config.MaxPendingBatches < config.MaxInFlight {
// The admin contract must reject policies Flutter cannot execute literally; normalizing this
// mismatch on-device would make an audited configuration differ from production behavior.
return storedGiftComboConfig{}, errors.New("maxPendingBatches must be greater than or equal to maxInFlight")
}
if config.RetryMaxAttempts < 1 || config.RetryMaxAttempts > 10 {
return storedGiftComboConfig{}, errors.New("retryMaxAttempts must be between 1 and 10")
}
if config.RetryBaseDelayMS < 100 || config.RetryBaseDelayMS > 5_000 {
return storedGiftComboConfig{}, errors.New("retryBaseDelayMs must be between 100 and 5000")
}
if config.RolloutBasisPoints < 0 || config.RolloutBasisPoints > 10_000 {
return storedGiftComboConfig{}, errors.New("rolloutBasisPoints must be between 0 and 10000")
}
return config, nil
}
func normalizeStoredGiftComboConfigForRead(config storedGiftComboConfig) (storedGiftComboConfig, error) {
if config.APIVersion == 1 {
// 兼容早期已落库的灰度行,但只迁移存储标记,不恢复客户端 V1 行为。管理员下一次
// 保存会写回 2Update 入口仍通过严格校验拒绝新建 apiVersion=1 配置。
config.APIVersion = 2
}
return normalizeStoredGiftComboConfig(config)
}
func storedGiftComboConfigFromRequest(request GiftComboConfigRequest) (storedGiftComboConfig, error) {
return normalizeStoredGiftComboConfig(storedGiftComboConfig{
Enabled: request.Enabled,
APIVersion: request.APIVersion,
WindowMS: request.WindowMS,
MaxGiftCountPerBatch: request.MaxGiftCountPerBatch,
MaxGiftUnitsPerBatch: request.MaxGiftUnitsPerBatch,
MaxInFlight: request.MaxInFlight,
MaxPendingBatches: request.MaxPendingBatches,
RetryMaxAttempts: request.RetryMaxAttempts,
RetryBaseDelayMS: request.RetryBaseDelayMS,
RolloutBasisPoints: request.RolloutBasisPoints,
})
}
func giftComboConfigDTO(appCode string, config storedGiftComboConfig, updatedAtMS int64) GiftComboConfigDTO {
return GiftComboConfigDTO{
AppCode: appctx.Normalize(appCode),
UpdatedAtMS: updatedAtMS,
GiftComboConfigRequest: GiftComboConfigRequest{
Enabled: config.Enabled,
APIVersion: config.APIVersion,
WindowMS: config.WindowMS,
MaxGiftCountPerBatch: config.MaxGiftCountPerBatch,
MaxGiftUnitsPerBatch: config.MaxGiftUnitsPerBatch,
MaxInFlight: config.MaxInFlight,
MaxPendingBatches: config.MaxPendingBatches,
RetryMaxAttempts: config.RetryMaxAttempts,
RetryBaseDelayMS: config.RetryBaseDelayMS,
RolloutBasisPoints: config.RolloutBasisPoints,
},
}
}
func (s *AppConfigService) GetGiftComboConfig(appCode string) (GiftComboConfigDTO, error) {
appCode = appctx.Normalize(appCode)
item, err := s.store.GetScopedAppConfig(appCode, giftComboConfigGroup, giftComboConfigKey)
if errors.Is(err, gorm.ErrRecordNotFound) {
return giftComboConfigDTO(appCode, defaultStoredGiftComboConfig(), 0), nil
}
if err != nil {
return GiftComboConfigDTO{}, err
}
config := defaultStoredGiftComboConfig()
if err := json.Unmarshal([]byte(strings.TrimSpace(item.Value)), &config); err != nil {
return GiftComboConfigDTO{}, fmt.Errorf("stored gift combo config is invalid: %w", err)
}
config, err = normalizeStoredGiftComboConfigForRead(config)
if err != nil {
return GiftComboConfigDTO{}, err
}
return giftComboConfigDTO(appCode, config, item.UpdatedAtMS), nil
}
func (s *AppConfigService) UpdateGiftComboConfig(appCode string, request GiftComboConfigRequest) (GiftComboConfigDTO, error) {
appCode = appctx.Normalize(appCode)
config, err := storedGiftComboConfigFromRequest(request)
if err != nil {
return GiftComboConfigDTO{}, err
}
raw, err := json.Marshal(config)
if err != nil {
return GiftComboConfigDTO{}, err
}
// UpsertScopedAppConfigs writes only the selected app_code, so a rollout or kill switch for one App
// cannot unexpectedly alter every tenant sharing this admin database.
if err := s.store.UpsertScopedAppConfigs(appCode, []model.AppConfig{{
AppCode: appCode,
Group: giftComboConfigGroup,
Key: giftComboConfigKey,
Value: string(raw),
Description: "Flutter room gift combo batching policy",
}}); err != nil {
return GiftComboConfigDTO{}, err
}
return s.GetGiftComboConfig(appCode)
}
func (h *Handler) GetGiftComboConfig(c *gin.Context) {
item, err := h.service.GetGiftComboConfig(appctx.FromContext(c.Request.Context()))
if err != nil {
response.ServerError(c, "获取送礼连击配置失败")
return
}
response.OK(c, item)
}
func (h *Handler) UpdateGiftComboConfig(c *gin.Context) {
var request GiftComboConfigRequest
if err := c.ShouldBindJSON(&request); err != nil {
response.BadRequest(c, "参数不正确")
return
}
item, err := h.service.UpdateGiftComboConfig(appctx.FromContext(c.Request.Context()), request)
if err != nil {
response.BadRequest(c, err.Error())
return
}
shared.OperationLog(c, h.audit, "update-room-gift-combo", "admin_app_configs", "success", fmt.Sprintf("app_code=%s enabled=%t rollout_bps=%d", item.AppCode, item.Enabled, item.RolloutBasisPoints))
response.OK(c, item)
}

View File

@ -0,0 +1,60 @@
package appconfig
import "testing"
func TestStoredGiftComboConfigFromRequestPreservesExplicitKillSwitch(t *testing.T) {
config, err := storedGiftComboConfigFromRequest(GiftComboConfigRequest{
Enabled: false,
APIVersion: 2,
WindowMS: 300,
MaxGiftCountPerBatch: 999,
MaxGiftUnitsPerBatch: 5_000,
MaxInFlight: 2,
MaxPendingBatches: 20,
RetryMaxAttempts: 5,
RetryBaseDelayMS: 250,
RolloutBasisPoints: 5_000,
})
if err != nil {
t.Fatalf("storedGiftComboConfigFromRequest failed: %v", err)
}
if config.Enabled || config.RolloutBasisPoints != 5_000 {
t.Fatalf("explicit kill switch changed during normalization: %+v", config)
}
}
func TestNormalizeStoredGiftComboConfigRejectsQueueWithoutBackpressure(t *testing.T) {
config := defaultStoredGiftComboConfig()
config.APIVersion = 1
if _, err := normalizeStoredGiftComboConfig(config); err == nil {
t.Fatal("apiVersion 1 must not turn the current Flutter back to the legacy HTTP contract")
}
config = defaultStoredGiftComboConfig()
config.MaxInFlight = 3
if _, err := normalizeStoredGiftComboConfig(config); err == nil {
t.Fatal("maxInFlight above the server contract must be rejected")
}
config = defaultStoredGiftComboConfig()
config.MaxPendingBatches = 0
if _, err := normalizeStoredGiftComboConfig(config); err == nil {
t.Fatal("unbounded/empty pending queue policy must be rejected")
}
config = defaultStoredGiftComboConfig()
config.MaxInFlight = 2
config.MaxPendingBatches = 1
if _, err := normalizeStoredGiftComboConfig(config); err == nil {
t.Fatal("pending queue smaller than in-flight concurrency must be rejected")
}
}
func TestNormalizeStoredGiftComboConfigForReadPromotesLegacyVersionMarker(t *testing.T) {
config := defaultStoredGiftComboConfig()
config.APIVersion = 1
resolved, err := normalizeStoredGiftComboConfigForRead(config)
if err != nil {
t.Fatalf("legacy stored config must remain readable: %v", err)
}
if resolved.APIVersion != 2 {
t.Fatalf("legacy stored marker was not promoted to V2: %+v", resolved)
}
}

View File

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

View File

@ -4,6 +4,7 @@ import (
"strconv"
"github.com/gin-gonic/gin"
"hyapp-admin-server/internal/appctx"
"hyapp-admin-server/internal/config"
"hyapp-admin-server/internal/integration/userclient"
"hyapp-admin-server/internal/middleware"
@ -33,6 +34,22 @@ func (h *Handler) DashboardOverview(c *gin.Context) {
response.OK(c, overview)
}
func (h *Handler) UserProfileOverview(c *gin.Context) {
overview, err := h.service.UserProfileOverview(c.Request.Context(), UserProfileOverviewQuery{
// app_code 显式 query 用于后台页面切换 App未传时回退鉴权中间件写入的 X-App-Code
// 避免页面只切 Header App 后仍静默读取 lalu。
AppCode: c.DefaultQuery("app_code", appctx.FromContext(c.Request.Context())),
StatTZ: c.Query("stat_tz"),
StartMS: parseInt64(c.Query("start_ms")),
EndMS: parseInt64(c.Query("end_ms")),
})
if err != nil {
response.ServerError(c, "获取用户画像总览失败")
return
}
response.OK(c, overview)
}
func (h *Handler) StatisticsOverview(c *gin.Context) {
overview, err := h.service.StatisticsOverview(c.Request.Context(), StatisticsQuery{
AppCode: c.DefaultQuery("app_code", "lalu"),

View File

@ -8,6 +8,7 @@ import (
func RegisterRoutes(protected *gin.RouterGroup, h *Handler) {
protected.GET("/dashboard/overview", middleware.RequirePermission("overview:view"), h.DashboardOverview)
protected.GET("/dashboard/user-profile-overview", middleware.RequirePermission("overview:view"), h.UserProfileOverview)
protected.GET("/statistics/overview", middleware.RequirePermission("overview:view"), h.StatisticsOverview)
protected.GET("/statistics/platform-grants/users", middleware.RequirePermission("overview:view"), h.PlatformGrantUsers)
protected.GET("/statistics/platform-grants/records", middleware.RequirePermission("overview:view"), h.PlatformGrantRecords)

View File

@ -3,6 +3,7 @@ package dashboard
import (
"bytes"
"context"
"database/sql"
"encoding/json"
"fmt"
"net/http"
@ -18,6 +19,7 @@ import (
type DashboardService struct {
store *repository.Store
userDB *sql.DB
cfg config.Config
httpClient *http.Client
user userclient.Client
@ -96,6 +98,14 @@ type ExternalDashboardRegionResolver interface {
type Option func(*DashboardService)
// WithUserDB 注入用户主数据只读连接Dashboard 用它计算已完成真实用户的人群快照,
// 并从 login_audit 读取最近一次成功登录版本。当天新增与活跃仍沿用 statistics-service 的事实投影。
func WithUserDB(userDB *sql.DB) Option {
return func(s *DashboardService) {
s.userDB = userDB
}
}
func WithExternalDashboardSources(sources ...ExternalDashboardSource) Option {
return func(s *DashboardService) {
for _, source := range sources {

View File

@ -0,0 +1,194 @@
package dashboard
import (
"context"
"fmt"
"strings"
"time"
"hyapp-admin-server/internal/appctx"
)
type UserProfileOverviewQuery struct {
AppCode string
StatTZ string
StartMS int64
EndMS int64
}
type UserProfileDistributionItem struct {
Key string `json:"key"`
Label string `json:"label"`
Count int64 `json:"count"`
}
type UserProfileOverview struct {
AppCode string `json:"appCode"`
TotalUsers int64 `json:"totalUsers"`
NewUsers int64 `json:"newUsers"`
ActiveUsers int64 `json:"activeUsers"`
GenderDistribution []UserProfileDistributionItem `json:"genderDistribution"`
AppVersionDistribution []UserProfileDistributionItem `json:"appVersionDistribution"`
UpdatedAtMS int64 `json:"updatedAtMs"`
}
func (s *DashboardService) UserProfileOverview(ctx context.Context, query UserProfileOverviewQuery) (UserProfileOverview, error) {
if s == nil || s.userDB == nil {
return UserProfileOverview{}, fmt.Errorf("user mysql is not configured")
}
query.AppCode = appctx.Normalize(query.AppCode)
totalUsers, genderDistribution, err := s.queryNaturalUserGenderDistribution(ctx, query.AppCode)
if err != nil {
return UserProfileOverview{}, fmt.Errorf("query dashboard user profile: %w", err)
}
// 新增和 DAU 必须沿用数据大屏的事件聚合口径;直接按 users.created_at_ms 或登录表重算会与
// StatisticsOverview 的 UserRegistered/活跃事实去重规则产生双重口径。
statisticsOverview, err := s.StatisticsOverview(ctx, StatisticsQuery{
AppCode: query.AppCode,
StatTZ: query.StatTZ,
StartMS: query.StartMS,
EndMS: query.EndMS,
})
if err != nil {
return UserProfileOverview{}, fmt.Errorf("query dashboard daily statistics: %w", err)
}
newUsers := anyInt64(statisticsOverview["new_users"])
activeUsers := anyInt64(statisticsOverview["active_users"])
versionDistribution, err := s.queryNaturalUserAppVersionDistribution(ctx, query.AppCode)
if err != nil {
return UserProfileOverview{}, fmt.Errorf("query dashboard app version distribution: %w", err)
}
return UserProfileOverview{
AppCode: query.AppCode,
TotalUsers: totalUsers,
NewUsers: newUsers,
ActiveUsers: activeUsers,
GenderDistribution: genderDistribution,
AppVersionDistribution: versionDistribution,
UpdatedAtMS: time.Now().UTC().UnixMilli(),
}, nil
}
func (s *DashboardService) queryNaturalUserGenderDistribution(ctx context.Context, appCode string) (int64, []UserProfileDistributionItem, error) {
rows, err := s.userDB.QueryContext(ctx, `
SELECT CASE
WHEN TRIM(COALESCE(gender, '')) = '' THEN 'unknown'
ELSE LOWER(TRIM(gender))
END AS gender_key,
COUNT(*) AS user_count
FROM users
WHERE app_code = ?
AND profile_completed = 1
AND LOWER(TRIM(COALESCE(source, ''))) NOT IN ('game_robot', 'quick_account')
GROUP BY gender_key
ORDER BY user_count DESC, gender_key ASC`, appCode)
if err != nil {
return 0, nil, err
}
defer rows.Close()
// 总人数直接求和同一条性别分组结果,让 totalUsers 与饼图永远共享 profile_completed/source 条件;
// 分开执行 COUNT 和 GROUP BY 容易在以后新增“真实用户”排除条件时只改到其中一处。
items := make([]UserProfileDistributionItem, 0)
var total int64
for rows.Next() {
var key string
var count int64
if err := rows.Scan(&key, &count); err != nil {
return 0, nil, err
}
key = normalizeDistributionKey(key)
items = append(items, UserProfileDistributionItem{Key: key, Label: genderDistributionLabel(key), Count: count})
total += count
}
if err := rows.Err(); err != nil {
return 0, nil, err
}
return total, items, nil
}
func (s *DashboardService) queryNaturalUserAppVersionDistribution(ctx context.Context, appCode string) ([]UserProfileDistributionItem, error) {
rows, err := s.userDB.QueryContext(ctx, `
WITH natural_users AS (
SELECT user_id
FROM users
WHERE app_code = ?
AND profile_completed = 1
AND LOWER(TRIM(COALESCE(source, ''))) NOT IN ('game_robot', 'quick_account')
), ranked_login AS (
SELECT audit.user_id,
TRIM(COALESCE(audit.app_version, '')) AS app_version,
ROW_NUMBER() OVER (
PARTITION BY audit.user_id
ORDER BY audit.created_at_ms DESC, audit.id DESC
) AS login_rank
FROM login_audit AS audit
INNER JOIN natural_users AS natural ON natural.user_id = audit.user_id
WHERE audit.app_code = ?
AND audit.result = 'success'
AND audit.blocked = 0
AND audit.login_type IN ('password', 'third_party', 'refresh')
)
SELECT CASE
WHEN latest.app_version IS NULL OR latest.app_version = '' THEN 'unknown'
ELSE latest.app_version
END AS version_key,
COUNT(*) AS user_count
FROM natural_users AS natural
LEFT JOIN ranked_login AS latest
ON latest.user_id = natural.user_id AND latest.login_rank = 1
GROUP BY version_key
ORDER BY user_count DESC, version_key ASC`, appCode, appCode)
if err != nil {
return nil, err
}
defer rows.Close()
items := make([]UserProfileDistributionItem, 0)
for rows.Next() {
var key string
var count int64
if err := rows.Scan(&key, &count); err != nil {
return nil, err
}
key = normalizeDistributionKey(key)
items = append(items, UserProfileDistributionItem{Key: key, Label: appVersionDistributionLabel(key), Count: count})
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
func normalizeDistributionKey(value string) string {
value = strings.TrimSpace(value)
if value == "" || strings.EqualFold(value, "unknown") || value == "<nil>" {
return "unknown"
}
return value
}
func genderDistributionLabel(key string) string {
switch strings.ToLower(key) {
case "male":
return "男"
case "female":
return "女"
case "non_binary", "nonbinary":
return "非二元"
case "unknown":
return "未知"
default:
return key
}
}
func appVersionDistributionLabel(key string) string {
if key == "unknown" {
return "未知版本"
}
return key
}

View File

@ -0,0 +1,137 @@
package dashboard
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/DATA-DOG/go-sqlmock"
"github.com/gin-gonic/gin"
"hyapp-admin-server/internal/appctx"
"hyapp-admin-server/internal/config"
"hyapp-admin-server/internal/middleware"
)
func TestUserProfileOverviewAggregatesNaturalUsersAndDailyStatistics(t *testing.T) {
userDB, sqlMock, err := sqlmock.New()
if err != nil {
t.Fatalf("create user sqlmock: %v", err)
}
defer userDB.Close()
sqlMock.ExpectQuery(`(?s)SELECT CASE.*COALESCE\(gender, ''\).*FROM users.*WHERE app_code = \?.*profile_completed = 1.*game_robot.*quick_account.*GROUP BY gender_key.*ORDER BY user_count DESC`).
WithArgs("huwaa").
WillReturnRows(sqlmock.NewRows([]string{"gender_key", "user_count"}).
AddRow("male", int64(4)).
AddRow("female", int64(2)).
AddRow("unknown", int64(1)))
sqlMock.ExpectQuery(`(?s)WITH natural_users AS.*FROM users.*app_code = \?.*profile_completed = 1.*game_robot.*quick_account.*ranked_login AS.*ROW_NUMBER\(\) OVER.*PARTITION BY audit.user_id.*ORDER BY audit.created_at_ms DESC, audit.id DESC.*audit.app_code = \?.*audit.result = 'success'.*audit.blocked = 0.*audit.login_type IN \('password', 'third_party', 'refresh'\).*LEFT JOIN ranked_login.*latest.login_rank = 1.*GROUP BY version_key`).
WithArgs("huwaa", "huwaa").
WillReturnRows(sqlmock.NewRows([]string{"version_key", "user_count"}).
AddRow("2.4.0", int64(4)).
AddRow("unknown", int64(3)))
requested := map[string]bool{}
statisticsServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requested[r.URL.Path] = true
query := r.URL.Query()
if query.Get("app_code") != "huwaa" || query.Get("stat_tz") != "Asia/Shanghai" ||
query.Get("start_ms") != "1783699200000" || query.Get("end_ms") != "1783785600000" {
http.Error(w, "unexpected statistics query: "+r.URL.RawQuery, http.StatusBadRequest)
return
}
w.Header().Set("Content-Type", "application/json")
switch r.URL.Path {
case "/internal/v1/statistics/overview":
_ = json.NewEncoder(w).Encode(map[string]any{"active_users": 5, "new_users": 2})
default:
http.NotFound(w, r)
}
}))
defer statisticsServer.Close()
service := NewService(nil, config.Config{StatisticsService: config.StatisticsServiceConfig{
BaseURL: statisticsServer.URL,
RequestTimeout: time.Second,
}}, nil, WithUserDB(userDB))
router := newDashboardUserProfileTestRouter(NewWithService(service), []string{"overview:view"})
request := httptest.NewRequest(http.MethodGet,
"/api/v1/dashboard/user-profile-overview?app_code=Huwaa&stat_tz=Asia%2FShanghai&start_ms=1783699200000&end_ms=1783785600000", nil)
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK {
t.Fatalf("status mismatch: got=%d body=%s", recorder.Code, recorder.Body.String())
}
var envelope struct {
Code int `json:"code"`
Data UserProfileOverview `json:"data"`
}
if err := json.Unmarshal(recorder.Body.Bytes(), &envelope); err != nil {
t.Fatalf("decode response: %v", err)
}
if envelope.Code != 0 || envelope.Data.AppCode != "huwaa" || envelope.Data.TotalUsers != 7 ||
envelope.Data.NewUsers != 2 || envelope.Data.ActiveUsers != 5 || envelope.Data.UpdatedAtMS <= 0 {
t.Fatalf("overview mismatch: %+v", envelope.Data)
}
if len(envelope.Data.GenderDistribution) != 3 ||
envelope.Data.GenderDistribution[0] != (UserProfileDistributionItem{Key: "male", Label: "男", Count: 4}) ||
envelope.Data.GenderDistribution[2] != (UserProfileDistributionItem{Key: "unknown", Label: "未知", Count: 1}) {
t.Fatalf("gender distribution mismatch: %+v", envelope.Data.GenderDistribution)
}
if len(envelope.Data.AppVersionDistribution) != 2 ||
envelope.Data.AppVersionDistribution[0] != (UserProfileDistributionItem{Key: "2.4.0", Label: "2.4.0", Count: 4}) ||
envelope.Data.AppVersionDistribution[1] != (UserProfileDistributionItem{Key: "unknown", Label: "未知版本", Count: 3}) {
t.Fatalf("version distribution mismatch: %+v", envelope.Data.AppVersionDistribution)
}
// 版本分布以全部自然用户为分母并直接查询 login_auditstatistics-service 只保留新增/DAU 口径。
if len(requested) != 1 || !requested["/internal/v1/statistics/overview"] {
t.Fatalf("statistics calls mismatch: %+v", requested)
}
if err := sqlMock.ExpectationsWereMet(); err != nil {
t.Fatalf("sql expectations mismatch: %v", err)
}
}
func TestGenderDistributionLabelMapsFlutterValues(t *testing.T) {
tests := map[string]string{
"male": "男",
"female": "女",
"non_binary": "非二元",
"unknown": "未知",
}
for input, expected := range tests {
if actual := genderDistributionLabel(input); actual != expected {
t.Fatalf("gender label mismatch: input=%q got=%q want=%q", input, actual, expected)
}
}
}
func TestUserProfileOverviewRouteRequiresOverviewPermission(t *testing.T) {
router := newDashboardUserProfileTestRouter(NewWithService(&DashboardService{}), nil)
request := httptest.NewRequest(http.MethodGet, "/api/v1/dashboard/user-profile-overview", nil)
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, request)
if recorder.Code != http.StatusForbidden {
t.Fatalf("status mismatch: got=%d body=%s", recorder.Code, recorder.Body.String())
}
}
func newDashboardUserProfileTestRouter(handler *Handler, permissions []string) *gin.Engine {
gin.SetMode(gin.TestMode)
router := gin.New()
router.Use(func(c *gin.Context) {
// 复现 AuthRequired/AppCode 已完成的上下文写入,让路由测试同时覆盖 overview:view 中间件和 query 优先级。
c.Request = c.Request.WithContext(appctx.WithContext(c.Request.Context(), "lalu"))
c.Set(middleware.ContextPermissions, permissions)
c.Next()
})
protected := router.Group("/api/v1")
RegisterRoutes(protected, handler)
return router
}

View File

@ -0,0 +1,556 @@
package databi
import (
"context"
"sort"
"strconv"
"strings"
"hyapp-admin-server/internal/modules/payment"
)
const financeRechargeTZOffsetMinutes int32 = 480
type financeRechargeValues struct {
GoogleUSDMinor int64
ThirdPartyUSDMinor int64
CoinSellerUSDMinor int64
}
func (v financeRechargeValues) userUSDMinor() int64 {
return v.GoogleUSDMinor + v.ThirdPartyUSDMinor
}
func (v financeRechargeValues) totalUSDMinor() int64 {
return v.userUSDMinor() + v.CoinSellerUSDMinor
}
func (s *Service) applyFinanceRechargeOverview(
ctx context.Context,
out *AppOverview,
query OverviewQuery,
regions []RegionInfo,
restricted bool,
allowedRegionIDs []int64,
) error {
if s == nil || s.financeRecharge == nil || out == nil {
return nil
}
scopeRegionIDs := []int64(nil)
if query.RegionID > 0 {
scopeRegionIDs = []int64{normalizeRegionID(regions, query.RegionID)}
} else if len(query.RegionIDs) > 0 {
scopeRegionIDs = normalizeRegionIDs(regions, query.RegionIDs)
} else if restricted {
scopeRegionIDs = normalizeRegionIDs(regions, allowedRegionIDs)
}
current, err := s.financeRecharge.FinanceRechargeOverview(ctx, payment.FinanceRechargeOverviewQuery{
AppCode: out.AppCode,
StartAtMS: query.StartMS,
EndAtMS: query.EndMS,
ScopeRegionIDs: scopeRegionIDs,
IncludeRegions: true,
TZOffsetMinutes: financeRechargeTZOffsetMinutes,
})
if err != nil {
return err
}
var previous payment.FinanceRechargeOverview
if query.StartMS > 0 && query.EndMS > query.StartMS {
periodLength := query.EndMS - query.StartMS
previous, err = s.financeRecharge.FinanceRechargeOverview(ctx, payment.FinanceRechargeOverviewQuery{
AppCode: out.AppCode,
StartAtMS: query.StartMS - periodLength,
EndAtMS: query.StartMS,
ScopeRegionIDs: scopeRegionIDs,
IncludeRegions: false,
TZOffsetMinutes: financeRechargeTZOffsetMinutes,
})
if err != nil {
return err
}
}
currentTotal := financeRechargeOverviewTotal(current)
previousTotal := financeRechargeOverviewTotal(previous)
applyFinanceRechargeValues(out.Total, currentTotal)
if query.StartMS > 0 && query.EndMS > query.StartMS {
out.Total["recharge_delta_rate"] = financeRechargeDelta(currentTotal.totalUSDMinor(), previousTotal.totalUSDMinor())
out.Total["user_recharge_delta_rate"] = financeRechargeDelta(currentTotal.userUSDMinor(), previousTotal.userUSDMinor())
}
// Finance 没有付费用户人数口径;金额切源后旧 ARPU/ARPPU 环比不可继续展示,否则分子来自旧 Dashboard。
out.Total["arpu_delta_rate"] = nil
out.Total["arppu_delta_rate"] = nil
out.DailySeries = overlayFinanceRechargeDaily(out.DailySeries, current.Daily)
out.RegionBreakdown, out.DailyRegionBreakdown = overlayFinanceRechargeRegions(
out.RegionBreakdown,
out.DailyRegionBreakdown,
current.Regions,
regions,
)
// Finance 页面没有国家维度,国家充值必须整体标为不可用;保留任何旧 Dashboard 金额都会导致
// App/区域合计与国家合计无法对账,并与下发的 Finance 指标来源说明矛盾。
markFinanceRechargeUnavailable(out.CountryBreakdown)
markFinanceRechargeUnavailable(out.DailyCountryBreakdown)
out.ReportMetricSources = appendFinanceRechargeMetricSources(out.ReportMetricSources)
return nil
}
func (s *Service) applyFinanceRechargeKpiWindow(
ctx context.Context,
appCode string,
startMS int64,
endMS int64,
total map[string]any,
regionMetrics map[int64]map[string]any,
) error {
financeOverview, err := s.financeRecharge.FinanceRechargeOverview(ctx, payment.FinanceRechargeOverviewQuery{
AppCode: appCode,
StartAtMS: startMS,
EndAtMS: endMS,
IncludeRegions: true,
TZOffsetMinutes: financeRechargeTZOffsetMinutes,
})
if err != nil {
return err
}
applyFinanceRechargeValues(total, financeRechargeOverviewTotal(financeOverview))
for regionID, row := range regionMetrics {
zeroFinanceRecharge([]map[string]any{row})
regionMetrics[regionID] = row
}
for _, financeRegion := range financeOverview.Regions {
row := regionMetrics[financeRegion.RegionID]
if row == nil {
row = map[string]any{"region_id": financeRegion.RegionID}
regionMetrics[financeRegion.RegionID] = row
}
// KPI 只消费区域总充值Finance 区域分布没有渠道拆分,不能用 0 伪造各渠道。
applyFinanceRechargeTotalOnly(row, financeRegion.TotalUSDMinor)
}
return nil
}
func financeRechargeOverviewTotal(overview payment.FinanceRechargeOverview) financeRechargeValues {
var total financeRechargeValues
for _, row := range overview.Daily {
total.GoogleUSDMinor += row.GoogleUSDMinor
total.ThirdPartyUSDMinor += row.ThirdPartyUSDMinor
total.CoinSellerUSDMinor += row.CoinSellerUSDMinor
}
return total
}
func financeRechargeValuesFromDaily(row payment.FinanceRechargeDailyBucket) financeRechargeValues {
return financeRechargeValues{
GoogleUSDMinor: row.GoogleUSDMinor,
ThirdPartyUSDMinor: row.ThirdPartyUSDMinor,
CoinSellerUSDMinor: row.CoinSellerUSDMinor,
}
}
func applyFinanceRechargeValues(row map[string]any, values financeRechargeValues) {
if row == nil {
return
}
row["google_recharge_usd_minor"] = values.GoogleUSDMinor
// Databi 历史字段名是 mifapay但 Finance 的 thirdParty 包含全部非 Google 三方渠道。
row["mifapay_recharge_usd_minor"] = values.ThirdPartyUSDMinor
row["third_party_recharge_usd_minor"] = values.ThirdPartyUSDMinor
row["coin_seller_recharge_usd_minor"] = values.CoinSellerUSDMinor
row["user_recharge_usd_minor"] = values.userUSDMinor()
row["recharge_usd_minor"] = values.totalUSDMinor()
row["total_recharge_usd_minor"] = values.totalUSDMinor()
row["new_user_recharge_usd_minor"] = nil
markFinanceRechargeDerivedUnavailable(row)
}
func applyFinanceRechargeTotalOnly(row map[string]any, totalUSDMinor int64) {
if row == nil {
return
}
markFinanceRechargeUnavailable([]map[string]any{row})
row["recharge_usd_minor"] = totalUSDMinor
row["total_recharge_usd_minor"] = totalUSDMinor
}
func markFinanceRechargeDerivedUnavailable(row map[string]any) {
if row == nil {
return
}
// Finance 的币商/H5 金额没有与旧 Dashboard paid_users 一致的去重人数,
// 因此 ARPU/ARPPU 不做跨来源除法;前端通过该标记在跨 App 聚合时继续保持缺失。
row["finance_recharge_aligned"] = true
row["arpu_usd_minor"] = nil
row["arppu_usd_minor"] = nil
}
func overlayFinanceRechargeDaily(existing []map[string]any, finance []payment.FinanceRechargeDailyBucket) []map[string]any {
byDay := make(map[string]payment.FinanceRechargeDailyBucket, len(finance))
for _, row := range finance {
if row.Date != "" {
byDay[row.Date] = row
}
}
seen := map[string]struct{}{}
for _, row := range existing {
day := rowString(row, "stat_day", "label")
bucket := byDay[day]
applyFinanceRechargeValues(row, financeRechargeValuesFromDaily(bucket))
if day != "" {
seen[day] = struct{}{}
}
}
for day, bucket := range byDay {
if _, ok := seen[day]; ok {
continue
}
row := map[string]any{"label": day, "stat_day": day}
applyFinanceRechargeValues(row, financeRechargeValuesFromDaily(bucket))
existing = append(existing, row)
}
sort.Slice(existing, func(i, j int) bool {
return rowString(existing[i], "stat_day", "label") < rowString(existing[j], "stat_day", "label")
})
return existing
}
func overlayFinanceRechargeRegions(
regionRows []map[string]any,
dailyRegionRows []map[string]any,
financeRegions []payment.FinanceRechargeRegionOverview,
regions []RegionInfo,
) ([]map[string]any, []map[string]any) {
zeroFinanceRecharge(regionRows)
dailyAvailable := false
for _, financeRegion := range financeRegions {
if financeRegion.DailyAvailable {
dailyAvailable = true
break
}
}
if dailyAvailable {
zeroFinanceRecharge(dailyRegionRows)
} else {
// 默认全量查询只复用 Finance 已有的区域总额;逐日渠道仅在用户显式选择区域时查询。
// 这里用 null 清掉旧 Dashboard 值,避免“未查询”被展示成 0 或旧口径。
markFinanceRechargeUnavailable(dailyRegionRows)
}
regionIndex := map[int64]map[string]any{}
for _, row := range regionRows {
if regionID, ok := rowInt64(row, "region_id", "regionId"); ok {
regionIndex[normalizeRegionID(regions, regionID)] = row
}
}
dailyIndex := map[string]map[string]any{}
for _, row := range dailyRegionRows {
regionID, ok := rowInt64(row, "region_id", "regionId")
if !ok {
continue
}
day := rowString(row, "stat_day", "label")
dailyIndex[financeRegionDayKey(normalizeRegionID(regions, regionID), day)] = row
}
for _, financeRegion := range financeRegions {
regionID := normalizeRegionID(regions, financeRegion.RegionID)
row := regionIndex[regionID]
if row == nil {
code, name := regionDisplay(regions, regionID)
row = map[string]any{"region_id": regionID, "region_code": code, "region_name": name}
regionRows = append(regionRows, row)
regionIndex[regionID] = row
}
var total financeRechargeValues
for _, dayBucket := range financeRegion.Daily {
values := financeRechargeValuesFromDaily(dayBucket)
total.GoogleUSDMinor += values.GoogleUSDMinor
total.ThirdPartyUSDMinor += values.ThirdPartyUSDMinor
total.CoinSellerUSDMinor += values.CoinSellerUSDMinor
key := financeRegionDayKey(regionID, dayBucket.Date)
dailyRow := dailyIndex[key]
if dailyRow == nil {
code, name := regionDisplay(regions, regionID)
dailyRow = map[string]any{
"label": dayBucket.Date,
"stat_day": dayBucket.Date,
"region_id": regionID,
"region_code": code,
"region_name": name,
}
dailyRegionRows = append(dailyRegionRows, dailyRow)
dailyIndex[key] = dailyRow
}
applyFinanceRechargeValues(dailyRow, values)
}
if financeRegion.DailyAvailable {
applyFinanceRechargeValues(row, total)
} else {
applyFinanceRechargeTotalOnly(row, financeRegion.TotalUSDMinor)
}
}
sort.Slice(regionRows, func(i, j int) bool {
left, _ := rowInt64(regionRows[i], "region_id", "regionId")
right, _ := rowInt64(regionRows[j], "region_id", "regionId")
return left < right
})
sort.Slice(dailyRegionRows, func(i, j int) bool {
leftDay := rowString(dailyRegionRows[i], "stat_day", "label")
rightDay := rowString(dailyRegionRows[j], "stat_day", "label")
if leftDay != rightDay {
return leftDay < rightDay
}
left, _ := rowInt64(dailyRegionRows[i], "region_id", "regionId")
right, _ := rowInt64(dailyRegionRows[j], "region_id", "regionId")
return left < right
})
return regionRows, dailyRegionRows
}
func zeroFinanceRecharge(rows []map[string]any) {
for _, row := range rows {
applyFinanceRechargeValues(row, financeRechargeValues{})
}
}
func markFinanceRechargeUnavailable(rows []map[string]any) {
for _, row := range rows {
row["google_recharge_usd_minor"] = nil
row["mifapay_recharge_usd_minor"] = nil
row["third_party_recharge_usd_minor"] = nil
row["coin_seller_recharge_usd_minor"] = nil
row["user_recharge_usd_minor"] = nil
row["recharge_usd_minor"] = nil
row["total_recharge_usd_minor"] = nil
row["new_user_recharge_usd_minor"] = nil
markFinanceRechargeDerivedUnavailable(row)
}
}
func requirementsIncludesRevenue(section string) bool {
switch strings.ToLower(strings.TrimSpace(section)) {
case "", "all", "revenue":
return true
default:
return false
}
}
func requirementsHasCohortFilter(query RequirementsQuery) bool {
for _, value := range []string{query.UserRole, query.PayerType, query.NewUserType} {
normalized := strings.ToLower(strings.TrimSpace(value))
if normalized != "" && normalized != "all" {
return true
}
}
return false
}
func overlayFinanceRechargeRequirements(sections []map[string]any, overview payment.FinanceRechargeOverview) {
byDay := make(map[string]financeRechargeValues, len(overview.Daily))
for _, bucket := range overview.Daily {
if bucket.Date != "" {
byDay[bucket.Date] = financeRechargeValuesFromDaily(bucket)
}
}
total := financeRechargeOverviewTotal(overview)
for _, section := range sections {
if rowString(section, "key") != "revenue" {
continue
}
section["recharge_source"] = "finance"
section["finance_recharge_available"] = true
updateFinanceRequirementMetadata(section, true)
if row, ok := section["total"].(map[string]any); ok {
applyFinanceRequirementAmounts(row, total, 1)
}
daily := anyRowSlice(section["daily_series"])
seen := map[string]struct{}{}
for _, row := range daily {
day := rowString(row, "stat_day", "label")
applyFinanceRequirementAmounts(row, byDay[day], 1)
if day != "" {
seen[day] = struct{}{}
}
}
for day, values := range byDay {
if _, ok := seen[day]; ok {
continue
}
row := map[string]any{"label": day, "stat_day": day}
applyFinanceRequirementAmounts(row, values, 1)
daily = append(daily, row)
}
sort.Slice(daily, func(i, j int) bool {
return rowString(daily[i], "stat_day", "label") < rowString(daily[j], "stat_day", "label")
})
section["daily_series"] = daily
if row, ok := section["average"].(map[string]any); ok {
applyFinanceRequirementAmounts(row, total, len(daily))
}
}
}
func markFinanceRechargeRequirementsUnavailable(sections []map[string]any) {
for _, section := range sections {
if rowString(section, "key") != "revenue" {
continue
}
section["recharge_source"] = "unavailable_for_cohort"
section["finance_recharge_available"] = false
updateFinanceRequirementMetadata(section, false)
if row, ok := section["total"].(map[string]any); ok {
markFinanceRequirementAmountsUnavailable(row)
}
if row, ok := section["average"].(map[string]any); ok {
markFinanceRequirementAmountsUnavailable(row)
}
for _, row := range anyRowSlice(section["daily_series"]) {
markFinanceRequirementAmountsUnavailable(row)
}
}
}
func applyFinanceRequirementAmounts(row map[string]any, values financeRechargeValues, dayCount int) {
if row == nil {
return
}
assign := func(key string, value int64) {
if dayCount > 1 {
row[key] = float64(value) / float64(dayCount)
return
}
row[key] = value
}
assign("recharge_usd_minor", values.totalUSDMinor())
assign("google_recharge_usd_minor", values.GoogleUSDMinor)
assign("third_party_recharge_usd_minor", values.ThirdPartyUSDMinor)
assign("coin_seller_recharge_usd_minor", values.CoinSellerUSDMinor)
// Finance 无首充/复购和去重付费人数维度,这些金额均值不能继续沿用宽表分子。
row["first_recharge_usd_minor"] = nil
row["repeat_recharge_usd_minor"] = nil
row["arpu_usd_minor"] = nil
row["arppu_usd_minor"] = nil
row["finance_recharge_aligned"] = true
}
func markFinanceRequirementAmountsUnavailable(row map[string]any) {
if row == nil {
return
}
for _, key := range []string{
"recharge_usd_minor",
"google_recharge_usd_minor",
"third_party_recharge_usd_minor",
"coin_seller_recharge_usd_minor",
"first_recharge_usd_minor",
"repeat_recharge_usd_minor",
"arpu_usd_minor",
"arppu_usd_minor",
} {
row[key] = nil
}
row["finance_recharge_aligned"] = true
}
func updateFinanceRequirementMetadata(section map[string]any, available bool) {
definitions := map[string]string{
"recharge_usd_minor": "Finance 成功充值总额 = Google + 非 Google 三方 + 币商充值。",
"google_recharge_usd_minor": "Finance Google 成功充值。",
"third_party_recharge_usd_minor": "Finance 非 Google 三方成功充值。",
"coin_seller_recharge_usd_minor": "Finance 已核验币商订单 + H5 币商身份成功充值。",
"first_recharge_usd_minor": "Finance 暂无首充金额拆分,本字段不可用。",
"repeat_recharge_usd_minor": "Finance 暂无复购金额拆分,本字段不可用。",
"arpu_usd_minor": "Finance 金额没有同口径活跃人数,本字段不可用。",
"arppu_usd_minor": "Finance 币商/H5 金额没有同口径付费人数,本字段不可用。",
}
if !available {
for _, key := range []string{"recharge_usd_minor", "google_recharge_usd_minor", "third_party_recharge_usd_minor", "coin_seller_recharge_usd_minor"} {
definitions[key] = "Finance 不支持当前角色/付费身份/新老用户筛选,本字段不可用。"
}
}
metricDefinitions := anyRowSlice(section["metric_definitions"])
definitionIndex := map[string]map[string]any{}
for _, row := range metricDefinitions {
definitionIndex[rowString(row, "metric", "field")] = row
}
for metric, definition := range definitions {
row := definitionIndex[metric]
if row == nil {
row = map[string]any{"metric": metric}
metricDefinitions = append(metricDefinitions, row)
}
row["definition"] = definition
}
section["metric_definitions"] = metricDefinitions
columns := anyRowSlice(section["columns"])
for _, column := range columns {
key := rowString(column, "key")
if definition, ok := definitions[key]; ok {
column["tooltip"] = definition
}
}
section["columns"] = columns
}
func financeRegionDayKey(regionID int64, day string) string {
return strconv.FormatInt(regionID, 10) + "|" + day
}
func financeRechargeDelta(current int64, previous int64) float64 {
if previous == 0 {
if current > 0 {
return 1
}
return 0
}
denominator := previous
if denominator < 0 {
denominator = -denominator
}
return float64(current-previous) / float64(denominator)
}
func appendFinanceRechargeMetricSources(value any) []map[string]any {
out := anyRowSlice(value)
fields := []struct {
field string
definition string
}{
{"recharge_usd_minor", "Finance 总充值 = Google + 三方 + 币商充值。"},
{"google_recharge_usd_minor", "Finance 谷歌成功充值账单。"},
{"mifapay_recharge_usd_minor", "Finance 非 Google 三方成功充值账单。"},
{"coin_seller_recharge_usd_minor", "Finance 已核验币商订单 + H5 币商身份成功充值。"},
}
for _, item := range fields {
replaced := false
for index := range out {
if rowString(out[index], "field") != item.field {
continue
}
out[index] = map[string]any{
"available": true,
"definition": item.definition,
"field": item.field,
"source": "Finance recharge-bills overview",
}
replaced = true
break
}
if !replaced {
out = append(out, map[string]any{
"available": true,
"definition": item.definition,
"field": item.field,
"source": "Finance recharge-bills overview",
})
}
}
return out
}

View File

@ -0,0 +1,217 @@
package databi
import (
"context"
"testing"
"hyapp-admin-server/internal/modules/payment"
)
type fakeFinanceRechargeOverviewSource struct {
calls []payment.FinanceRechargeOverviewQuery
}
func (f *fakeFinanceRechargeOverviewSource) FinanceRechargeOverview(_ context.Context, query payment.FinanceRechargeOverviewQuery) (payment.FinanceRechargeOverview, error) {
f.calls = append(f.calls, query)
if query.EndAtMS == 200 {
return payment.FinanceRechargeOverview{Daily: []payment.FinanceRechargeDailyBucket{{
Date: "1970-01-01", GoogleUSDMinor: 50, ThirdPartyUSDMinor: 100, CoinSellerUSDMinor: 50,
}}}, nil
}
region := payment.FinanceRechargeRegionOverview{RegionID: 7, TotalUSDMinor: 150}
if len(query.ScopeRegionIDs) > 0 {
region.DailyAvailable = true
region.Daily = []payment.FinanceRechargeDailyBucket{{
Date: "2026-07-10", GoogleUSDMinor: 40, ThirdPartyUSDMinor: 50, CoinSellerUSDMinor: 60,
}}
}
return payment.FinanceRechargeOverview{
Daily: []payment.FinanceRechargeDailyBucket{
{Date: "2026-07-10", GoogleUSDMinor: 100, ThirdPartyUSDMinor: 200, CoinSellerUSDMinor: 300},
{Date: "2026-07-11", GoogleUSDMinor: 10, ThirdPartyUSDMinor: 20, CoinSellerUSDMinor: 30},
},
Regions: []payment.FinanceRechargeRegionOverview{region},
}, nil
}
func TestApplyFinanceRechargeOverviewReplacesDashboardRechargeMetrics(t *testing.T) {
finance := &fakeFinanceRechargeOverviewSource{}
service := &Service{financeRecharge: finance}
out := AppOverview{
AppCode: "aslan",
Total: map[string]any{
"active_users": int64(10), "paid_users": int64(2), "recharge_usd_minor": int64(9999),
"coin_seller_recharge_usd_minor": int64(8888), "arpu_delta_rate": 0.9, "arppu_delta_rate": 0.8,
},
DailySeries: []map[string]any{{
"stat_day": "2026-07-10", "active_users": int64(5), "paid_users": int64(1),
"recharge_usd_minor": int64(9999), "coin_seller_recharge_usd_minor": int64(8888),
}},
RegionBreakdown: []map[string]any{{
"region_id": int64(7), "active_users": int64(5), "paid_users": int64(1),
"recharge_usd_minor": int64(9999), "coin_seller_recharge_usd_minor": int64(8888),
}, {
"region_id": int64(8), "active_users": int64(3), "paid_users": int64(1),
"recharge_usd_minor": int64(7777), "coin_seller_recharge_usd_minor": int64(6666),
}},
DailyRegionBreakdown: []map[string]any{{
"region_id": int64(7), "stat_day": "2026-07-10", "active_users": int64(5), "paid_users": int64(1),
"recharge_usd_minor": int64(9999), "coin_seller_recharge_usd_minor": int64(8888),
}, {
"region_id": int64(8), "stat_day": "2026-07-10", "active_users": int64(3), "paid_users": int64(1),
"recharge_usd_minor": int64(7777), "coin_seller_recharge_usd_minor": int64(6666),
}},
CountryBreakdown: []map[string]any{{
"country_code": "SA", "recharge_usd_minor": int64(9999), "coin_seller_recharge_usd_minor": int64(8888),
}},
DailyCountryBreakdown: []map[string]any{{
"country_code": "SA", "stat_day": "2026-07-10", "recharge_usd_minor": int64(9999), "coin_seller_recharge_usd_minor": int64(8888),
}},
}
query := OverviewQuery{StartMS: 200, EndMS: 300}
regions := []RegionInfo{
{AppCode: "aslan", RegionID: 7, RegionCode: "SA", RegionName: "沙特"},
{AppCode: "aslan", RegionID: 8, RegionCode: "AE", RegionName: "阿联酋"},
}
if err := service.applyFinanceRechargeOverview(context.Background(), &out, query, regions, false, nil); err != nil {
t.Fatalf("apply Finance recharge overview failed: %v", err)
}
if got := mustMetric(t, out.Total, "recharge_usd_minor"); got != 660 {
t.Fatalf("total recharge should use Finance channels: got %d", got)
}
if got := mustMetric(t, out.Total, "coin_seller_recharge_usd_minor"); got != 330 {
t.Fatalf("coin seller recharge should use Finance verified/H5 amount: got %d", got)
}
if got := mustMetric(t, out.Total, "mifapay_recharge_usd_minor"); got != 220 {
t.Fatalf("third-party recharge should use Finance amount: got %d", got)
}
if delta, ok := out.Total["recharge_delta_rate"].(float64); !ok || delta != 2.3 {
t.Fatalf("recharge delta should compare the previous Finance range: %#v", out.Total["recharge_delta_rate"])
}
if out.Total["arpu_delta_rate"] != nil || out.Total["arppu_delta_rate"] != nil {
t.Fatalf("amount-derived deltas must not retain the old Dashboard numerator: %#v", out.Total)
}
if out.Total["arpu_usd_minor"] != nil || out.Total["arppu_usd_minor"] != nil || out.Total["finance_recharge_aligned"] != true {
t.Fatalf("Finance recharge must not be divided by old Dashboard users: %#v", out.Total)
}
if len(out.DailySeries) != 2 || mustMetric(t, out.DailySeries[0], "recharge_usd_minor") != 600 || mustMetric(t, out.DailySeries[1], "recharge_usd_minor") != 60 {
t.Fatalf("daily series should be replaced and missing Finance days appended: %#v", out.DailySeries)
}
if mustMetric(t, out.RegionBreakdown[0], "recharge_usd_minor") != 150 || out.RegionBreakdown[0]["coin_seller_recharge_usd_minor"] != nil {
t.Fatalf("global region rows should use Finance totals without inventing channel splits: %#v", out.RegionBreakdown)
}
if mustMetric(t, out.RegionBreakdown[1], "recharge_usd_minor") != 0 || out.DailyRegionBreakdown[0]["recharge_usd_minor"] != nil || out.DailyRegionBreakdown[1]["recharge_usd_minor"] != nil {
t.Fatalf("regions/days missing from Finance must not retain Dashboard recharge: region=%#v daily=%#v", out.RegionBreakdown, out.DailyRegionBreakdown)
}
if out.CountryBreakdown[0]["recharge_usd_minor"] != nil || out.CountryBreakdown[0]["coin_seller_recharge_usd_minor"] != nil || out.DailyCountryBreakdown[0]["recharge_usd_minor"] != nil {
t.Fatalf("country recharge must be unavailable because Finance has no country dimension: %#v %#v", out.CountryBreakdown, out.DailyCountryBreakdown)
}
if len(finance.calls) != 2 || !finance.calls[0].IncludeRegions || finance.calls[1].IncludeRegions || finance.calls[0].TZOffsetMinutes != 480 {
t.Fatalf("current/previous Finance queries mismatch: %#v", finance.calls)
}
}
func TestApplyFinanceRechargeOverviewLoadsDailyChannelsOnlyForExplicitRegions(t *testing.T) {
finance := &fakeFinanceRechargeOverviewSource{}
service := &Service{financeRecharge: finance}
out := AppOverview{
AppCode: "aslan",
Total: map[string]any{},
RegionBreakdown: []map[string]any{{"region_id": int64(7)}},
DailyRegionBreakdown: []map[string]any{{"region_id": int64(7), "stat_day": "2026-07-10"}},
}
query := OverviewQuery{StartMS: 200, EndMS: 300, RegionIDs: []int64{7}}
regions := []RegionInfo{{AppCode: "aslan", RegionID: 7, RegionCode: "SA", RegionName: "沙特"}}
if err := service.applyFinanceRechargeOverview(context.Background(), &out, query, regions, false, nil); err != nil {
t.Fatalf("apply scoped Finance recharge overview failed: %v", err)
}
if mustMetric(t, out.RegionBreakdown[0], "recharge_usd_minor") != 150 || mustMetric(t, out.DailyRegionBreakdown[0], "coin_seller_recharge_usd_minor") != 60 {
t.Fatalf("explicit regions should receive Finance daily channels: region=%#v daily=%#v", out.RegionBreakdown, out.DailyRegionBreakdown)
}
if len(finance.calls) != 2 || len(finance.calls[0].ScopeRegionIDs) != 1 || finance.calls[0].ScopeRegionIDs[0] != 7 || finance.calls[1].ScopeRegionIDs[0] != 7 {
t.Fatalf("current and previous windows must keep the explicit region scope: %#v", finance.calls)
}
}
func TestApplyFinanceRechargeKpiWindowUsesFinanceTotalsAndRegions(t *testing.T) {
finance := &fakeFinanceRechargeOverviewSource{}
service := &Service{financeRecharge: finance}
total := map[string]any{"recharge_usd_minor": int64(9999), "coin_seller_recharge_usd_minor": int64(8888)}
regions := map[int64]map[string]any{7: {
"region_id": int64(7), "recharge_usd_minor": int64(9999), "coin_seller_recharge_usd_minor": int64(8888),
}}
if err := service.applyFinanceRechargeKpiWindow(context.Background(), "aslan", 200, 300, total, regions); err != nil {
t.Fatalf("apply Finance KPI window failed: %v", err)
}
if mustMetric(t, total, "recharge_usd_minor") != 660 || mustMetric(t, regions[7], "recharge_usd_minor") != 150 {
t.Fatalf("KPI window should use Finance total/region values: total=%#v regions=%#v", total, regions)
}
if len(finance.calls) != 1 || !finance.calls[0].IncludeRegions {
t.Fatalf("KPI window should issue one Finance query with regions: %#v", finance.calls)
}
}
func TestOverlayFinanceRechargeRequirementsUsesFinanceAmounts(t *testing.T) {
sections := []map[string]any{{
"key": "revenue",
"total": map[string]any{"recharge_usd_minor": int64(9999), "recharge_users": int64(4), "arppu_usd_minor": int64(123)},
"average": map[string]any{"recharge_usd_minor": float64(9999)},
"daily_series": []any{
map[string]any{"stat_day": "2026-07-10", "recharge_usd_minor": int64(9999), "recharge_users": int64(3)},
map[string]any{"stat_day": "2026-07-11", "recharge_usd_minor": int64(9999), "recharge_users": int64(1)},
},
"columns": []any{map[string]any{"key": "coin_seller_recharge_usd_minor"}},
"metric_definitions": []any{map[string]any{"metric": "coin_seller_recharge_usd_minor", "definition": "old"}},
}}
overlayFinanceRechargeRequirements(sections, payment.FinanceRechargeOverview{Daily: []payment.FinanceRechargeDailyBucket{
{Date: "2026-07-10", GoogleUSDMinor: 100, ThirdPartyUSDMinor: 200, CoinSellerUSDMinor: 300},
{Date: "2026-07-11", GoogleUSDMinor: 10, ThirdPartyUSDMinor: 20, CoinSellerUSDMinor: 30},
}})
total := sections[0]["total"].(map[string]any)
daily := anyRowSlice(sections[0]["daily_series"])
average := sections[0]["average"].(map[string]any)
if mustMetric(t, total, "recharge_usd_minor") != 660 || mustMetric(t, total, "coin_seller_recharge_usd_minor") != 330 {
t.Fatalf("requirements total should use Finance channels: %#v", total)
}
if mustMetric(t, daily[0], "recharge_usd_minor") != 600 || mustMetric(t, daily[1], "recharge_usd_minor") != 60 {
t.Fatalf("requirements daily should use Finance channels: %#v", daily)
}
if average["recharge_usd_minor"] != float64(330) || total["recharge_users"] != int64(4) || total["arppu_usd_minor"] != nil {
t.Fatalf("requirements average/non-amount fields mismatch: total=%#v average=%#v", total, average)
}
if sections[0]["recharge_source"] != "finance" || sections[0]["finance_recharge_available"] != true {
t.Fatalf("requirements must expose Finance source metadata: %#v", sections[0])
}
}
func TestFinanceRechargeRequirementsCohortAmountsAreUnavailable(t *testing.T) {
query := RequirementsQuery{UserRole: "host", PayerType: "all", NewUserType: "all"}
if !requirementsHasCohortFilter(query) || !requirementsIncludesRevenue("revenue") {
t.Fatalf("cohort/revenue detection mismatch")
}
sections := []map[string]any{{
"key": "revenue",
"total": map[string]any{"recharge_usd_minor": int64(9999), "recharge_users": int64(2)},
"average": map[string]any{"recharge_usd_minor": float64(9999)},
"daily_series": []any{map[string]any{"stat_day": "2026-07-10", "recharge_usd_minor": int64(9999)}},
}}
markFinanceRechargeRequirementsUnavailable(sections)
total := sections[0]["total"].(map[string]any)
if total["recharge_usd_minor"] != nil || total["recharge_users"] != int64(2) || sections[0]["finance_recharge_available"] != false {
t.Fatalf("cohort should retain counts but hide non-Finance amounts: %#v", sections[0])
}
}
func mustMetric(t *testing.T, row map[string]any, key string) int64 {
t.Helper()
value, ok := metricInt64(row[key])
if !ok {
t.Fatalf("metric %s missing from %#v", key, row)
}
return value
}

View File

@ -40,11 +40,12 @@ func (h *Handler) Overview(c *gin.Context) {
return
}
result, err := h.service.Overview(c.Request.Context(), access, OverviewQuery{
StatTZ: c.Query("stat_tz"),
StartMS: queryInt64(c, "start_ms"),
EndMS: queryInt64(c, "end_ms"),
AppCodes: splitCSV(c.Query("app_codes")),
RegionID: queryInt64(c, "region_id"),
StatTZ: c.Query("stat_tz"),
StartMS: queryInt64(c, "start_ms"),
EndMS: queryInt64(c, "end_ms"),
AppCodes: splitCSV(c.Query("app_codes")),
RegionID: queryInt64(c, "region_id"),
RegionIDs: queryInt64CSV(c.Query("region_ids")),
})
if err != nil {
response.ServerError(c, "获取 BI 统计数据失败")

View File

@ -49,6 +49,7 @@ type Service struct {
dashboards *dashboard.DashboardService
apps *appregistry.Service
user userclient.Client
financeRecharge payment.FinanceRechargeOverviewSource
legacyApps []LegacyAppDescriptor
legacyRegionCatalogs []LegacyRegionCatalogSource
@ -115,6 +116,14 @@ func WithLegacyApps(apps ...LegacyAppDescriptor) ServiceOption {
}
}
// WithFinanceRechargeOverviewSource 强制 Social BI 的充值渠道复用 Finance 口径;
// 不在 Databi 内复制订单、H5 或 legacy 账单 SQL避免两个页面再次漂移。
func WithFinanceRechargeOverviewSource(source payment.FinanceRechargeOverviewSource) ServiceOption {
return func(s *Service) {
s.financeRecharge = source
}
}
func WithLegacyRegionCatalogs(catalogs ...LegacyRegionCatalogSource) ServiceOption {
return func(s *Service) {
for _, catalog := range catalogs {
@ -242,11 +251,12 @@ func (s *Service) listOperators(actorUserID uint, viewAll bool) ([]OperatorInfo,
}
type OverviewQuery struct {
StatTZ string
StartMS int64
EndMS int64
AppCodes []string
RegionID int64
StatTZ string
StartMS int64
EndMS int64
AppCodes []string
RegionID int64
RegionIDs []int64
}
type AppOverview struct {
@ -483,6 +493,31 @@ func (s *Service) appRequirements(ctx context.Context, app AppInfo, regions []Re
return out
}
out.Sections = anyRowSlice(requirements["sections"])
if s.financeRecharge != nil && requirementsIncludesRevenue(query.Section) {
if requirementsHasCohortFilter(query) {
// Finance 不具备角色/付费身份/新老用户维度;筛选后必须把金额标为不可用,
// 不能把全量 Finance 金额灌进 cohort也不能静默展示旧宽表金额冒充 Finance。
markFinanceRechargeRequirementsUnavailable(out.Sections)
} else {
financeRegionIDs := regionIDs
if query.RegionID > 0 {
financeRegionIDs = []int64{normalizeRegionID(regions, query.RegionID)}
}
financeOverview, financeErr := s.financeRecharge.FinanceRechargeOverview(ctx, payment.FinanceRechargeOverviewQuery{
AppCode: app.AppCode,
StartAtMS: query.StartMS,
EndAtMS: query.EndMS,
ScopeRegionIDs: financeRegionIDs,
IncludeRegions: false,
TZOffsetMinutes: financeRechargeTZOffsetMinutes,
})
if financeErr != nil {
out.Error = fmt.Sprintf("获取 Finance 充值统计失败: %v", financeErr)
return out
}
overlayFinanceRechargeRequirements(out.Sections, financeOverview)
}
}
out.UpdatedAtMS = time.Now().UnixMilli()
return out
}
@ -589,6 +624,17 @@ func (s *Service) appOverview(ctx context.Context, app AppInfo, regions []Region
out.Error = "该 App 未配置此区域"
return out
}
requestRegionIDs := normalizeRegionIDs(regions, query.RegionIDs)
if len(query.RegionIDs) > 0 && len(requestRegionIDs) == 0 {
out.Error = "该 App 未配置所选区域"
return out
}
for _, regionID := range requestRegionIDs {
if !access.Allows(app.AppCode, regionID) {
out.Error = "没有该区域的数据权限"
return out
}
}
overview, err := s.statisticsOverview(ctx, dashboard.StatisticsQuery{
AppCode: app.AppCode,
@ -608,13 +654,19 @@ func (s *Service) appOverview(ctx context.Context, app AppInfo, regions []Region
dailyCountryRows := anyRowSlice(overview["daily_country_breakdown"])
resolve := regionResolver(regions)
restricted := !allowAll && query.RegionID == 0
restricted := !allowAll && query.RegionID == 0 && len(requestRegionIDs) == 0
filterRegionIDs := requestRegionIDs
if restricted {
allowedSet := int64Set(allowedIDs)
countryRows = filterRowsByRegion(countryRows, resolve, allowedSet)
dailyCountryRows = filterRowsByRegion(dailyCountryRows, resolve, allowedSet)
filterRegionIDs = allowedIDs
out.Restricted = true
out.AllowedRegionIDs = allowedIDs
}
if query.RegionID == 0 && len(filterRegionIDs) > 0 {
// statistics-service 目前只有单区域参数;多区域在已返回的国家投影上裁剪并重算非充值指标,
// 充值随后由 Finance 按同一组区域重新覆盖,避免顶栏多选退回旧统计口径。
allowedSet := int64Set(filterRegionIDs)
countryRows = filterRowsByRegion(countryRows, resolve, allowedSet)
dailyCountryRows = filterRowsByRegion(dailyCountryRows, resolve, allowedSet)
totalAcc := newMetricAccumulator()
for _, row := range countryRows {
@ -633,6 +685,13 @@ func (s *Service) appOverview(ctx context.Context, app AppInfo, regions []Region
out.DailyCountryBreakdown = dailyCountryRows
out.ReportMetricSources = overview["report_metric_sources"]
out.UpdatedAtMS = overview["updated_at_ms"]
if s.financeRecharge != nil {
// Social BI 的充值卡片、趋势和区域下钻必须与 Finance 同源;
// Finance 查询失败时整 App 显式报错,不能静默回退到旧后台库存流水。
if err := s.applyFinanceRechargeOverview(ctx, &out, query, regions, restricted, allowedIDs); err != nil {
out.Error = fmt.Sprintf("获取 Finance 充值统计失败: %v", err)
}
}
return out
}
@ -1035,7 +1094,15 @@ func (s *Service) kpiWindow(ctx context.Context, app AppInfo, resolve func(map[s
regionMetrics[regionID] = row
}
}
return topLevelMetrics(overview), regionMetrics, nil
total := topLevelMetrics(overview)
if s.financeRecharge != nil {
// 运营中心的人×App×区域充值 KPI 与经营概览共用 Finance 口径;
// 否则同一路径切换菜单会出现两套充值金额。
if err := s.applyFinanceRechargeKpiWindow(ctx, app.AppCode, startMS, endMS, total, regionMetrics); err != nil {
return nil, nil, err
}
}
return total, regionMetrics, nil
}
func scopeMetrics(total map[string]any, regions map[int64]map[string]any, regionID int64) map[string]any {

View File

@ -6,7 +6,7 @@ type createCoinSellerRechargeOrderRequest struct {
AppCode string `json:"appCode"`
TargetUserID string `json:"targetUserId"`
USDAmount float64 `json:"usdAmount"`
CoinAmount int64 `json:"coinAmount"`
IsMakeup bool `json:"isMakeup"`
ProviderCode string `json:"providerCode"`
Chain string `json:"chain"`
ExternalOrderNo string `json:"externalOrderNo"`
@ -19,6 +19,12 @@ type createCoinSellerRechargeOrderRequest struct {
Remark string `json:"remark"`
}
type coinSellerRechargeQuoteRequest struct {
AppCode string `json:"appCode"`
TargetUserID string `json:"targetUserId"`
USDAmount float64 `json:"usdAmount"`
}
type verifyCoinSellerRechargeReceiptRequest struct {
AppCode string `json:"appCode"`
USDAmount float64 `json:"usdAmount"`

View File

@ -0,0 +1,305 @@
package financeorder
import (
"context"
"encoding/json"
"errors"
"fmt"
"math"
"sort"
"strconv"
"strings"
"hyapp-admin-server/internal/appctx"
"hyapp-admin-server/internal/model"
"hyapp-admin-server/internal/modules/shared"
"gorm.io/gorm"
)
const (
coinSellerRechargeExchangeRateGroup = "finance-coin-seller-recharge"
coinSellerRechargeExchangeRateKey = "exchange-rate-policy"
maxExchangeRateTiers = 100
maxExchangeRateWhitelistEntries = 1000
)
type coinSellerRechargeExchangeRateTierRequest struct {
MinUSDAmount float64 `json:"minUsdAmount"`
MaxUSDAmount float64 `json:"maxUsdAmount"`
CoinsPerUSD int64 `json:"coinsPerUsd"`
}
type coinSellerRechargeExchangeRateWhitelistRequest struct {
UserID string `json:"userId"`
CoinsPerUSD int64 `json:"coinsPerUsd"`
}
type coinSellerRechargeExchangeRatePolicyRequest struct {
Tiers []coinSellerRechargeExchangeRateTierRequest `json:"tiers"`
Whitelist []coinSellerRechargeExchangeRateWhitelistRequest `json:"whitelist"`
}
type coinSellerRechargeExchangeRateTierDTO struct {
MinUSDAmount string `json:"minUsdAmount"`
MaxUSDAmount string `json:"maxUsdAmount"`
CoinsPerUSD int64 `json:"coinsPerUsd"`
}
type coinSellerRechargeExchangeRateWhitelistDTO struct {
UserID string `json:"userId"`
CoinsPerUSD int64 `json:"coinsPerUsd"`
}
type coinSellerRechargeExchangeRatePolicyDTO struct {
AppCode string `json:"appCode"`
Tiers []coinSellerRechargeExchangeRateTierDTO `json:"tiers"`
Whitelist []coinSellerRechargeExchangeRateWhitelistDTO `json:"whitelist"`
UpdatedAtMS int64 `json:"updatedAtMs"`
}
type coinSellerRechargeQuoteDTO struct {
AppCode string `json:"appCode"`
TargetUserID string `json:"targetUserId"`
TargetDisplayUserID string `json:"targetDisplayUserId"`
USDAmount string `json:"usdAmount"`
CoinAmount int64 `json:"coinAmount"`
CoinsPerUSD int64 `json:"coinsPerUsd"`
RateSource string `json:"rateSource"`
MatchedUserID string `json:"matchedUserId,omitempty"`
MinUSDAmount string `json:"minUsdAmount,omitempty"`
MaxUSDAmount string `json:"maxUsdAmount,omitempty"`
}
type coinSellerRechargeExchangeRatePolicy struct {
Tiers []coinSellerRechargeExchangeRateTier `json:"tiers"`
Whitelist []coinSellerRechargeExchangeRateWhitelist `json:"whitelist"`
}
type coinSellerRechargeExchangeRateTier struct {
MinUSDMinor int64 `json:"minUsdMinor"`
MaxUSDMinor int64 `json:"maxUsdMinor"`
CoinsPerUSD int64 `json:"coinsPerUsd"`
}
type coinSellerRechargeExchangeRateWhitelist struct {
UserID string `json:"userId"`
CoinsPerUSD int64 `json:"coinsPerUsd"`
}
func (s *Service) QuoteOrder(ctx context.Context, actor shared.Actor, req coinSellerRechargeQuoteRequest, requestID string) (*coinSellerRechargeQuoteDTO, error) {
if actor.UserID == 0 || !hasPermission(actor.Permissions, permissionCoinSellerRechargeCreate) {
return nil, errCoinSellerRechargeForbidden
}
appCode := appctx.Normalize(req.AppCode)
targetUserID := strings.TrimSpace(req.TargetUserID)
usdAmount, usdMinorAmount, err := normalizeUSD(req.USDAmount)
if err != nil || appCode == "" || targetUserID == "" {
return nil, errInvalidCoinSellerRechargeOrderInput
}
// 报价先解析真实币商,保证白名单同时支持运营输入的展示 ID 和账务内部 user_id且不会给非币商生成可提交结果。
target, err := s.resolveCoinSellerTarget(ctx, requestID, appCode, targetUserID)
if err != nil {
return nil, err
}
quote, err := s.quoteResolvedTarget(appCode, targetUserID, target, usdMinorAmount)
if err != nil {
return nil, err
}
quote.USDAmount = usdAmount
return &quote, nil
}
func (s *Service) createOrderCoinAmount(input normalizedCreateOrderInput, target coinSellerTarget) (int64, error) {
if input.IsMakeup {
// 补单仍绑定并校验真实充值凭证、记录 USD 充值统计但明确不读取汇率、不产生金币发放GrantOrder 会走既有 0 金币完成分支。
return 0, nil
}
quote, err := s.quoteResolvedTarget(input.AppCode, input.TargetUserID, target, input.USDMinorAmount)
if err != nil {
return 0, err
}
return quote.CoinAmount, nil
}
func (s *Service) GetExchangeRatePolicy(appCode string) (*coinSellerRechargeExchangeRatePolicyDTO, error) {
appCode = appctx.Normalize(appCode)
if appCode == "" {
return nil, errInvalidCoinSellerRechargeOrderInput
}
policy, updatedAtMS, err := s.loadExchangeRatePolicy(appCode)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return exchangeRatePolicyDTO(appCode, coinSellerRechargeExchangeRatePolicy{}, 0), nil
}
return nil, err
}
return exchangeRatePolicyDTO(appCode, policy, updatedAtMS), nil
}
func (s *Service) ReplaceExchangeRatePolicy(actor shared.Actor, appCode string, req coinSellerRechargeExchangeRatePolicyRequest) (*coinSellerRechargeExchangeRatePolicyDTO, error) {
if actor.UserID == 0 || !hasPermission(actor.Permissions, permissionCoinSellerExchangeRate) {
return nil, errCoinSellerRechargeForbidden
}
appCode = appctx.Normalize(appCode)
policy, err := normalizeExchangeRatePolicyRequest(req)
if err != nil || appCode == "" {
if err != nil {
return nil, err
}
return nil, errInvalidCoinSellerRechargeOrderInput
}
value, err := json.Marshal(policy)
if err != nil {
return nil, err
}
// 配置写入 app-scoped 行,禁止一个 APP 的汇率覆盖其他 APP订单创建会重新读取此行不信任前端缓存的报价。
if err := s.store.UpsertScopedAppConfigs(appCode, []model.AppConfig{{
Group: coinSellerRechargeExchangeRateGroup,
Key: coinSellerRechargeExchangeRateKey,
Value: string(value),
Description: "币商充值 USD 金币兑换区间与用户白名单",
}}); err != nil {
return nil, err
}
return s.GetExchangeRatePolicy(appCode)
}
func (s *Service) quoteResolvedTarget(appCode string, rawTarget string, target coinSellerTarget, usdMinorAmount int64) (coinSellerRechargeQuoteDTO, error) {
policy, _, err := s.loadExchangeRatePolicy(appCode)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return coinSellerRechargeQuoteDTO{}, errors.New("当前 APP 尚未配置币商充值金币汇率")
}
return coinSellerRechargeQuoteDTO{}, err
}
quote, err := calculateCoinSellerRechargeQuote(policy, rawTarget, target, usdMinorAmount)
if err != nil {
return coinSellerRechargeQuoteDTO{}, err
}
quote.AppCode = appCode
quote.TargetUserID = formatInt64(target.UserID)
quote.TargetDisplayUserID = target.DisplayUserID
return quote, nil
}
func (s *Service) loadExchangeRatePolicy(appCode string) (coinSellerRechargeExchangeRatePolicy, int64, error) {
if s == nil || s.store == nil {
return coinSellerRechargeExchangeRatePolicy{}, 0, errors.New("admin store is not configured")
}
item, err := s.store.GetScopedAppConfig(appctx.Normalize(appCode), coinSellerRechargeExchangeRateGroup, coinSellerRechargeExchangeRateKey)
if err != nil {
return coinSellerRechargeExchangeRatePolicy{}, 0, err
}
var policy coinSellerRechargeExchangeRatePolicy
if err := json.Unmarshal([]byte(item.Value), &policy); err != nil {
return coinSellerRechargeExchangeRatePolicy{}, 0, errors.New("币商充值汇率配置损坏")
}
return policy, item.UpdatedAtMS, nil
}
func normalizeExchangeRatePolicyRequest(req coinSellerRechargeExchangeRatePolicyRequest) (coinSellerRechargeExchangeRatePolicy, error) {
if len(req.Tiers) == 0 {
return coinSellerRechargeExchangeRatePolicy{}, errors.New("至少配置一个 USD 金额区间")
}
if len(req.Tiers) > maxExchangeRateTiers || len(req.Whitelist) > maxExchangeRateWhitelistEntries {
return coinSellerRechargeExchangeRatePolicy{}, errors.New("币商充值汇率配置条目过多")
}
policy := coinSellerRechargeExchangeRatePolicy{
Tiers: make([]coinSellerRechargeExchangeRateTier, 0, len(req.Tiers)),
Whitelist: make([]coinSellerRechargeExchangeRateWhitelist, 0, len(req.Whitelist)),
}
for _, item := range req.Tiers {
_, minMinor, minErr := normalizeUSD(item.MinUSDAmount)
_, maxMinor, maxErr := normalizeUSD(item.MaxUSDAmount)
if minErr != nil || maxErr != nil || minMinor <= 0 || maxMinor < minMinor || item.CoinsPerUSD <= 0 {
return coinSellerRechargeExchangeRatePolicy{}, errors.New("USD 金额区间或金币汇率不正确")
}
policy.Tiers = append(policy.Tiers, coinSellerRechargeExchangeRateTier{MinUSDMinor: minMinor, MaxUSDMinor: maxMinor, CoinsPerUSD: item.CoinsPerUSD})
}
sort.Slice(policy.Tiers, func(i, j int) bool { return policy.Tiers[i].MinUSDMinor < policy.Tiers[j].MinUSDMinor })
for index := 1; index < len(policy.Tiers); index++ {
if policy.Tiers[index].MinUSDMinor <= policy.Tiers[index-1].MaxUSDMinor {
return coinSellerRechargeExchangeRatePolicy{}, errors.New("USD 金额区间不能重叠")
}
}
seen := make(map[string]struct{}, len(req.Whitelist))
for _, item := range req.Whitelist {
userID := strings.TrimSpace(item.UserID)
if userID == "" || item.CoinsPerUSD <= 0 {
return coinSellerRechargeExchangeRatePolicy{}, errors.New("白名单用户 ID 或金币汇率不正确")
}
if _, exists := seen[userID]; exists {
return coinSellerRechargeExchangeRatePolicy{}, fmt.Errorf("白名单用户 ID 重复: %s", userID)
}
seen[userID] = struct{}{}
policy.Whitelist = append(policy.Whitelist, coinSellerRechargeExchangeRateWhitelist{UserID: userID, CoinsPerUSD: item.CoinsPerUSD})
}
return policy, nil
}
func calculateCoinSellerRechargeQuote(policy coinSellerRechargeExchangeRatePolicy, rawTarget string, target coinSellerTarget, usdMinorAmount int64) (coinSellerRechargeQuoteDTO, error) {
if usdMinorAmount <= 0 {
return coinSellerRechargeQuoteDTO{}, errors.New("充值美金数量不正确")
}
targetIDs := map[string]struct{}{
strings.TrimSpace(rawTarget): {},
strings.TrimSpace(target.DisplayUserID): {},
formatInt64(target.UserID): {},
}
for _, item := range policy.Whitelist {
if _, ok := targetIDs[item.UserID]; ok {
return quoteFromRate(usdMinorAmount, item.CoinsPerUSD, "whitelist", item.UserID, 0, 0)
}
}
for _, item := range policy.Tiers {
if usdMinorAmount >= item.MinUSDMinor && usdMinorAmount <= item.MaxUSDMinor {
return quoteFromRate(usdMinorAmount, item.CoinsPerUSD, "tier", "", item.MinUSDMinor, item.MaxUSDMinor)
}
}
return coinSellerRechargeQuoteDTO{}, errors.New("USD 金额未命中当前 APP 的金币兑换区间")
}
func quoteFromRate(usdMinorAmount int64, coinsPerUSD int64, source string, matchedUserID string, minMinor int64, maxMinor int64) (coinSellerRechargeQuoteDTO, error) {
// USD 最多两位小数;金币必须为整数,因此按最接近整数四舍五入,创建订单时会用同一公式重新计算。
if coinsPerUSD <= 0 || usdMinorAmount > (math.MaxInt64-50)/coinsPerUSD {
return coinSellerRechargeQuoteDTO{}, errors.New("计算后的充值金币数量过大")
}
coinAmount := (usdMinorAmount*coinsPerUSD + 50) / 100
return coinSellerRechargeQuoteDTO{
USDAmount: formatUSDMinorAmount(usdMinorAmount),
CoinAmount: coinAmount,
CoinsPerUSD: coinsPerUSD,
RateSource: source,
MatchedUserID: matchedUserID,
MinUSDAmount: optionalUSDMinorAmount(minMinor),
MaxUSDAmount: optionalUSDMinorAmount(maxMinor),
}, nil
}
func exchangeRatePolicyDTO(appCode string, policy coinSellerRechargeExchangeRatePolicy, updatedAtMS int64) *coinSellerRechargeExchangeRatePolicyDTO {
dto := &coinSellerRechargeExchangeRatePolicyDTO{
AppCode: appCode,
Tiers: make([]coinSellerRechargeExchangeRateTierDTO, 0, len(policy.Tiers)),
Whitelist: make([]coinSellerRechargeExchangeRateWhitelistDTO, 0, len(policy.Whitelist)),
UpdatedAtMS: updatedAtMS,
}
for _, item := range policy.Tiers {
dto.Tiers = append(dto.Tiers, coinSellerRechargeExchangeRateTierDTO{MinUSDAmount: formatUSDMinorAmount(item.MinUSDMinor), MaxUSDAmount: formatUSDMinorAmount(item.MaxUSDMinor), CoinsPerUSD: item.CoinsPerUSD})
}
for _, item := range policy.Whitelist {
dto.Whitelist = append(dto.Whitelist, coinSellerRechargeExchangeRateWhitelistDTO{UserID: item.UserID, CoinsPerUSD: item.CoinsPerUSD})
}
return dto
}
func formatUSDMinorAmount(value int64) string {
return strconv.FormatInt(value/100, 10) + "." + fmt.Sprintf("%02d", value%100)
}
func optionalUSDMinorAmount(value int64) string {
if value <= 0 {
return ""
}
return formatUSDMinorAmount(value)
}

View File

@ -78,6 +78,44 @@ func (h *Handler) CreateCoinSellerRechargeOrder(c *gin.Context) {
response.Created(c, item)
}
func (h *Handler) QuoteCoinSellerRechargeOrder(c *gin.Context) {
var req coinSellerRechargeQuoteRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "币商充值报价参数不正确")
return
}
item, err := h.service.QuoteOrder(c.Request.Context(), shared.ActorFromContext(c), req, middleware.CurrentRequestID(c))
if err != nil {
writeServiceError(c, err, "生成币商充值报价失败")
return
}
response.OK(c, item)
}
func (h *Handler) GetCoinSellerRechargeExchangeRate(c *gin.Context) {
item, err := h.service.GetExchangeRatePolicy(c.Param("app_code"))
if err != nil {
writeServiceError(c, err, "获取币商充值汇率配置失败")
return
}
response.OK(c, item)
}
func (h *Handler) ReplaceCoinSellerRechargeExchangeRate(c *gin.Context) {
var req coinSellerRechargeExchangeRatePolicyRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "币商充值汇率配置参数不正确")
return
}
item, err := h.service.ReplaceExchangeRatePolicy(shared.ActorFromContext(c), c.Param("app_code"), req)
if err != nil {
writeServiceError(c, err, "保存币商充值汇率配置失败")
return
}
shared.OperationLogWithResourceID(c, h.audit, "replace-coin-seller-recharge-exchange-rate", "admin_app_configs", item.AppCode, "success", "tiers="+strconv.Itoa(len(item.Tiers))+",whitelist="+strconv.Itoa(len(item.Whitelist)))
response.OK(c, item)
}
func (h *Handler) VerifyCoinSellerRechargeReceipt(c *gin.Context) {
var req verifyCoinSellerRechargeReceiptRequest
if err := c.ShouldBindJSON(&req); err != nil {

View File

@ -12,8 +12,11 @@ func RegisterRoutes(protected *gin.RouterGroup, h *Handler) {
}
protected.GET("/admin/finance/orders/coin-seller-recharges", middleware.RequirePermission(permissionCoinSellerRechargeView), h.ListCoinSellerRechargeOrders)
protected.GET("/admin/finance/orders/coin-seller-recharges/:order_id", middleware.RequirePermission(permissionCoinSellerRechargeView), h.GetCoinSellerRechargeOrder)
protected.POST("/admin/finance/orders/coin-seller-recharges/quotes", middleware.RequirePermission(permissionCoinSellerRechargeCreate), h.QuoteCoinSellerRechargeOrder)
protected.POST("/admin/finance/orders/coin-seller-recharges/receipt-verifications", middleware.RequirePermission(permissionCoinSellerRechargeVerify), h.VerifyCoinSellerRechargeReceipt)
protected.POST("/admin/finance/orders/coin-seller-recharges", middleware.RequirePermission(permissionCoinSellerRechargeCreate), h.CreateCoinSellerRechargeOrder)
protected.POST("/admin/finance/orders/coin-seller-recharges/:order_id/verify", middleware.RequirePermission(permissionCoinSellerRechargeVerify), h.VerifyCoinSellerRechargeOrder)
protected.POST("/admin/finance/orders/coin-seller-recharges/:order_id/grant", middleware.RequirePermission(permissionCoinSellerRechargeGrant), h.GrantCoinSellerRechargeOrder)
protected.GET("/admin/finance/coin-seller-recharge-exchange-rates/:app_code", middleware.RequirePermission(permissionCoinSellerExchangeRate), h.GetCoinSellerRechargeExchangeRate)
protected.PUT("/admin/finance/coin-seller-recharge-exchange-rates/:app_code", middleware.RequirePermission(permissionCoinSellerExchangeRate), h.ReplaceCoinSellerRechargeExchangeRate)
}

View File

@ -29,6 +29,7 @@ const (
permissionCoinSellerRechargeCreate = "finance-order:coin-seller-recharge:create"
permissionCoinSellerRechargeVerify = "finance-order:coin-seller-recharge:verify"
permissionCoinSellerRechargeGrant = "finance-order:coin-seller-recharge:grant"
permissionCoinSellerExchangeRate = "coin-seller:exchange-rate"
coinSellerRechargeAssetType = "COIN_SELLER_COIN"
coinSellerRechargeStockType = "usdt_purchase"
@ -106,6 +107,16 @@ func (s *Service) CreateOrder(ctx context.Context, actor shared.Actor, req creat
if err != nil {
return nil, err
}
// 金币数只由当前 APP 汇率配置生成;即使调用方绕过前端直接提交 coinAmount也不会进入请求结构或账务结果。
target, err := s.resolveCoinSellerTarget(ctx, requestID, input.AppCode, input.TargetUserID)
if err != nil {
return nil, err
}
coinAmount, err := s.createOrderCoinAmount(input, target)
if err != nil {
return nil, err
}
input.CoinAmount = coinAmount
// 前端“校验通过”只能改善操作体验,不能作为资金事实;创建时必须复用同一套服务端校验,避免篡改请求直接落库。
receipt, err := s.verifyReceiptInput(ctx, input.receiptVerificationInput(), strings.TrimSpace(requestID))
if err != nil {
@ -117,11 +128,6 @@ func (s *Service) CreateOrder(ctx context.Context, actor shared.Actor, req creat
if err := verifyReceiptMatchesInput(input, receipt); err != nil {
return nil, err
}
// 钱包入账对象必须在订单占用前确认身份Lalu 查 user-serviceAslan/Yumi 走 legacy writer避免跨账套写错目标。
target, err := s.resolveCoinSellerTarget(ctx, requestID, input.AppCode, input.TargetUserID)
if err != nil {
return nil, err
}
nowMS := s.now().UnixMilli()
verifiedAtMS := receipt.VerifiedAtMS
if verifiedAtMS <= 0 {
@ -347,6 +353,7 @@ type normalizedCreateOrderInput struct {
USDAmount string
USDMinorAmount int64
CoinAmount int64
IsMakeup bool
ProviderCode string
Chain string
ExternalOrderNo string
@ -398,7 +405,7 @@ func normalizeCreateOrderRequest(req createCoinSellerRechargeOrderRequest) (norm
return normalizedCreateOrderInput{}, err
}
externalOrderNo := strings.TrimSpace(req.ExternalOrderNo)
if appCode == "" || targetUserID == "" || externalOrderNo == "" || req.CoinAmount < 0 {
if appCode == "" || targetUserID == "" || externalOrderNo == "" {
return normalizedCreateOrderInput{}, errInvalidCoinSellerRechargeOrderInput
}
if len(externalOrderNo) > 128 {
@ -437,7 +444,8 @@ func normalizeCreateOrderRequest(req createCoinSellerRechargeOrderRequest) (norm
TargetUserID: targetUserID,
USDAmount: usdAmount,
USDMinorAmount: usdMinorAmount,
CoinAmount: req.CoinAmount,
CoinAmount: 0,
IsMakeup: req.IsMakeup,
ProviderCode: providerCode,
Chain: chain,
ExternalOrderNo: externalOrderNo,

View File

@ -15,7 +15,6 @@ func TestNormalizeCreateOrderRequiresExplicitUSDTChain(t *testing.T) {
AppCode: "lalu",
TargetUserID: "10001",
USDAmount: 12.34,
CoinAmount: 1200,
ProviderCode: "usdt",
ExternalOrderNo: "0xabc",
})
@ -27,7 +26,6 @@ func TestNormalizeCreateOrderRequiresExplicitUSDTChain(t *testing.T) {
AppCode: "lalu",
TargetUserID: "10001",
USDAmount: 12.34,
CoinAmount: 1200,
ProviderCode: "usdt",
Chain: "bsc",
ExternalOrderNo: "0xabc",
@ -46,7 +44,6 @@ func TestNormalizeCreateOrderRequiresProviderCurrencyAmount(t *testing.T) {
AppCode: "lalu",
TargetUserID: "10001",
USDAmount: 12.34,
CoinAmount: 1200,
ProviderCode: providerCode,
ExternalOrderNo: providerCode + "-order",
})
@ -58,7 +55,6 @@ func TestNormalizeCreateOrderRequiresProviderCurrencyAmount(t *testing.T) {
AppCode: "lalu",
TargetUserID: "10001",
USDAmount: 12.34,
CoinAmount: 1200,
ProviderCode: providerCode,
ExternalOrderNo: providerCode + "-order",
ProviderCurrencyCode: " php ",
@ -106,12 +102,11 @@ func TestNormalizeReceiptVerificationCarriesProviderCurrencyAmount(t *testing.T)
}
}
func TestNormalizeCreateOrderAllowsZeroCoinAmount(t *testing.T) {
func TestNormalizeCreateOrderLeavesCoinAmountForServerQuote(t *testing.T) {
input, err := normalizeCreateOrderRequest(createCoinSellerRechargeOrderRequest{
AppCode: "lalu",
TargetUserID: "10001",
USDAmount: 12.34,
CoinAmount: 0,
ProviderCode: "mifapay",
ExternalOrderNo: "MIFA-001",
ProviderCurrencyCode: "PHP",
@ -119,10 +114,89 @@ func TestNormalizeCreateOrderAllowsZeroCoinAmount(t *testing.T) {
Remark: " 补单,不发金币 ",
})
if err != nil {
t.Fatalf("zero coin makeup order should be accepted: %v", err)
t.Fatalf("create order input should be accepted before server quote: %v", err)
}
if input.CoinAmount != 0 || input.USDMinorAmount != 1234 || input.Remark != "补单,不发金币" {
t.Fatalf("normalized zero coin order mismatch: %+v", input)
t.Fatalf("normalized order should not contain a client-controlled coin amount: %+v", input)
}
}
func TestNormalizeCreateOrderCarriesExplicitMakeupState(t *testing.T) {
input, err := normalizeCreateOrderRequest(createCoinSellerRechargeOrderRequest{
AppCode: "lalu",
TargetUserID: "10001",
USDAmount: 12.34,
IsMakeup: true,
ProviderCode: "mifapay",
ExternalOrderNo: "MIFA-MAKEUP-001",
ProviderCurrencyCode: "PHP",
ProviderAmountMinor: 1234,
})
if err != nil {
t.Fatalf("normalize makeup order failed: %v", err)
}
if !input.IsMakeup || input.CoinAmount != 0 {
t.Fatalf("makeup state must force the later server branch to keep zero coins: %+v", input)
}
}
func TestCreateOrderMakeupBypassesExchangeRateAndForcesZeroCoins(t *testing.T) {
service := NewService(nil, nil, nil)
coinAmount, err := service.createOrderCoinAmount(normalizedCreateOrderInput{
AppCode: "lalu",
TargetUserID: "123456",
USDMinorAmount: 15000,
IsMakeup: true,
}, coinSellerTarget{UserID: 9988, DisplayUserID: "123456"})
if err != nil || coinAmount != 0 {
t.Fatalf("makeup must not require an exchange-rate config and must force zero coins: amount=%d err=%v", coinAmount, err)
}
}
func TestCoinSellerRechargeQuotePrefersWhitelistOverAmountTier(t *testing.T) {
policy := coinSellerRechargeExchangeRatePolicy{
Tiers: []coinSellerRechargeExchangeRateTier{
{MinUSDMinor: 10000, MaxUSDMinor: 20000, CoinsPerUSD: 80000},
{MinUSDMinor: 20100, MaxUSDMinor: 30000, CoinsPerUSD: 90000},
},
Whitelist: []coinSellerRechargeExchangeRateWhitelist{{UserID: "123456", CoinsPerUSD: 100000}},
}
quote, err := calculateCoinSellerRechargeQuote(policy, "123456", coinSellerTarget{UserID: 9988, DisplayUserID: "123456"}, 15000)
if err != nil {
t.Fatalf("calculate whitelist quote failed: %v", err)
}
if quote.RateSource != "whitelist" || quote.CoinsPerUSD != 100000 || quote.CoinAmount != 15000000 || quote.MatchedUserID != "123456" {
t.Fatalf("whitelist quote mismatch: %+v", quote)
}
tierQuote, err := calculateCoinSellerRechargeQuote(policy, "654321", coinSellerTarget{UserID: 7766, DisplayUserID: "654321"}, 25000)
if err != nil {
t.Fatalf("calculate tier quote failed: %v", err)
}
if tierQuote.RateSource != "tier" || tierQuote.CoinsPerUSD != 90000 || tierQuote.CoinAmount != 22500000 || tierQuote.MinUSDAmount != "201.00" || tierQuote.MaxUSDAmount != "300.00" {
t.Fatalf("tier quote mismatch: %+v", tierQuote)
}
}
func TestNormalizeCoinSellerRechargeExchangeRateRejectsOverlapsAndDuplicateWhitelist(t *testing.T) {
_, err := normalizeExchangeRatePolicyRequest(coinSellerRechargeExchangeRatePolicyRequest{Tiers: []coinSellerRechargeExchangeRateTierRequest{
{MinUSDAmount: 100, MaxUSDAmount: 200, CoinsPerUSD: 80000},
{MinUSDAmount: 200, MaxUSDAmount: 300, CoinsPerUSD: 90000},
}})
if err == nil || !strings.Contains(err.Error(), "不能重叠") {
t.Fatalf("expected overlap validation error, got %v", err)
}
_, err = normalizeExchangeRatePolicyRequest(coinSellerRechargeExchangeRatePolicyRequest{
Tiers: []coinSellerRechargeExchangeRateTierRequest{{MinUSDAmount: 1, MaxUSDAmount: 10, CoinsPerUSD: 80000}},
Whitelist: []coinSellerRechargeExchangeRateWhitelistRequest{
{UserID: "123456", CoinsPerUSD: 100000},
{UserID: "123456", CoinsPerUSD: 110000},
},
})
if err == nil || !strings.Contains(err.Error(), "重复") {
t.Fatalf("expected duplicate whitelist validation error, got %v", err)
}
}

View File

@ -115,6 +115,62 @@ func TestGetRechargeBillSummaryKeepsOrdinaryThirdParty(t *testing.T) {
}
}
func TestFinanceRechargeOverviewReusesFinanceChannelsAndDropsWalletStockCoinSeller(t *testing.T) {
wallet := &mockPaymentWallet{}
wallet.rechargeOverviewFn = func(req *walletv1.GetRechargeBillOverviewRequest) (*walletv1.GetRechargeBillOverviewResponse, error) {
regionID := req.GetRegionId()
googleUSD, thirdPartyUSD := int64(100), int64(200)
if regionID == 7 {
googleUSD, thirdPartyUSD = 40, 60
}
switch req.GetRechargeType() {
case "google_play_recharge":
return &walletv1.GetRechargeBillOverviewResponse{Regions: []*walletv1.RechargeBillRegionBucket{{RegionId: 7, UsdMinorAmount: googleUSD}}}, nil
case "third_party":
return &walletv1.GetRechargeBillOverviewResponse{Regions: []*walletv1.RechargeBillRegionBucket{{RegionId: 7, UsdMinorAmount: thirdPartyUSD}}}, nil
default:
return &walletv1.GetRechargeBillOverviewResponse{Daily: []*walletv1.RechargeBillDailyBucket{{
Date: "2026-07-10", GoogleUsdMinor: googleUSD, ThirdPartyUsdMinor: thirdPartyUSD,
// wallet overview 的旧库存币商列必须被 Finance 订单/H5 聚合清零后重填。
CoinSellerUsdMinor: 9999,
}}}, nil
}
}
handler := New(wallet, nil, nil, nil, nil)
overview, err := handler.FinanceRechargeOverview(context.Background(), FinanceRechargeOverviewQuery{
AppCode: "lalu", StartAtMS: 100, EndAtMS: 200, IncludeRegions: true, TZOffsetMinutes: 480,
})
if err != nil {
t.Fatalf("FinanceRechargeOverview failed: %v", err)
}
if len(overview.Daily) != 1 || overview.Daily[0].GoogleUSDMinor != 100 || overview.Daily[0].ThirdPartyUSDMinor != 200 || overview.Daily[0].CoinSellerUSDMinor != 0 {
t.Fatalf("global Finance channels mismatch: %#v", overview.Daily)
}
if len(overview.Regions) != 1 || overview.Regions[0].RegionID != 7 || overview.Regions[0].TotalUSDMinor != 300 || overview.Regions[0].DailyAvailable || len(overview.Regions[0].Daily) != 0 {
t.Fatalf("global region Finance total mismatch: %#v", overview.Regions)
}
if len(wallet.rechargeOverviewCalls) != 3 {
t.Fatalf("default overview must not fan out by region, got %d calls: %#v", len(wallet.rechargeOverviewCalls), wallet.rechargeOverviewCalls)
}
scoped, err := handler.FinanceRechargeOverview(context.Background(), FinanceRechargeOverviewQuery{
AppCode: "lalu", StartAtMS: 100, EndAtMS: 200, ScopeRegionIDs: []int64{7}, IncludeRegions: true, TZOffsetMinutes: 480,
})
if err != nil {
t.Fatalf("scoped FinanceRechargeOverview failed: %v", err)
}
if len(scoped.Daily) != 1 || scoped.Daily[0].GoogleUSDMinor != 40 || scoped.Daily[0].ThirdPartyUSDMinor != 60 {
t.Fatalf("scoped global channels mismatch: %#v", scoped.Daily)
}
if len(scoped.Regions) != 1 || !scoped.Regions[0].DailyAvailable || scoped.Regions[0].TotalUSDMinor != 100 || len(scoped.Regions[0].Daily) != 1 {
t.Fatalf("explicit region scope must include daily channels: %#v", scoped.Regions)
}
if len(wallet.rechargeOverviewCalls) != 4 {
t.Fatalf("explicit one-region scope should add only one Finance aggregation, got %d calls: %#v", len(wallet.rechargeOverviewCalls), wallet.rechargeOverviewCalls)
}
}
func TestListThirdPartyPaymentChannelsForwardsIncludeDisabledMethods(t *testing.T) {
wallet := &mockPaymentWallet{thirdPartyChannelsResp: &walletv1.ListThirdPartyPaymentChannelsResponse{
Channels: []*walletv1.ThirdPartyPaymentChannel{{
@ -784,6 +840,8 @@ type mockPaymentWallet struct {
lastRechargeBills *walletv1.ListRechargeBillsRequest
rechargeBillsResp *walletv1.ListRechargeBillsResponse
rechargeOverviewFn func(*walletv1.GetRechargeBillOverviewRequest) (*walletv1.GetRechargeBillOverviewResponse, error)
rechargeOverviewCalls []*walletv1.GetRechargeBillOverviewRequest
rechargeSummaryResp *walletv1.GetRechargeBillSummaryResponse
lastThirdPartyChannels *walletv1.ListThirdPartyPaymentChannelsRequest
thirdPartyChannelsResp *walletv1.ListThirdPartyPaymentChannelsResponse
@ -816,6 +874,14 @@ func (m *mockPaymentWallet) GetRechargeBillSummary(_ context.Context, req *walle
return &walletv1.GetRechargeBillSummaryResponse{}, nil
}
func (m *mockPaymentWallet) GetRechargeBillOverview(_ context.Context, req *walletv1.GetRechargeBillOverviewRequest) (*walletv1.GetRechargeBillOverviewResponse, error) {
m.rechargeOverviewCalls = append(m.rechargeOverviewCalls, req)
if m.rechargeOverviewFn != nil {
return m.rechargeOverviewFn(req)
}
return &walletv1.GetRechargeBillOverviewResponse{}, nil
}
func (m *mockPaymentWallet) ListThirdPartyPaymentChannels(_ context.Context, req *walletv1.ListThirdPartyPaymentChannelsRequest) (*walletv1.ListThirdPartyPaymentChannelsResponse, error) {
m.lastThirdPartyChannels = req
if m.thirdPartyChannelsResp != nil {

View File

@ -1,7 +1,11 @@
package payment
import (
"context"
"fmt"
"sort"
"strings"
"sync"
"hyapp-admin-server/internal/appctx"
"hyapp-admin-server/internal/middleware"
@ -55,6 +59,44 @@ type rechargeBillOverviewDTO struct {
Withdrawal rechargeBillWithdrawalStatsDTO `json:"withdrawal"`
}
// FinanceRechargeOverviewQuery 是供其他后台模块复用财务充值口径的只读查询。
// ScopeRegionIDs 非空时只汇总这些区域,避免数据范围受限的 BI 查询先读全量再裁剪。
type FinanceRechargeOverviewQuery struct {
AppCode string
StartAtMS int64
EndAtMS int64
ScopeRegionIDs []int64
IncludeRegions bool
TZOffsetMinutes int32
}
// FinanceRechargeDailyBucket 与 Finance 页面趋势使用同一组渠道字段和切日口径。
type FinanceRechargeDailyBucket struct {
Date string
GoogleUSDMinor int64
ThirdPartyUSDMinor int64
CoinSellerUSDMinor int64
}
// FinanceRechargeRegionOverview 在全量查询时只返回 Finance 页面已有的区域总额;
// 仅显式区域范围查询才补齐逐日渠道,避免默认概览对所有区域做 N+1 聚合。
type FinanceRechargeRegionOverview struct {
RegionID int64
TotalUSDMinor int64
DailyAvailable bool
Daily []FinanceRechargeDailyBucket
}
type FinanceRechargeOverview struct {
Daily []FinanceRechargeDailyBucket
Regions []FinanceRechargeRegionOverview
}
// FinanceRechargeOverviewSource 让 Social BI 依赖 Finance 的业务口径,而不是依赖 payment HTTP handler。
type FinanceRechargeOverviewSource interface {
FinanceRechargeOverview(context.Context, FinanceRechargeOverviewQuery) (FinanceRechargeOverview, error)
}
// GetRechargeBillOverview 返回财务概览聚合:按日趋势、区域分布与谷歌实付覆盖度;筛选口径与账单列表一致。
func (h *Handler) GetRechargeBillOverview(c *gin.Context) {
options := shared.ListOptions(c)
@ -80,48 +122,6 @@ func (h *Handler) GetRechargeBillOverview(c *gin.Context) {
response.OK(c, rechargeBillOverviewDTO{Daily: []rechargeBillDailyBucketDTO{}, Regions: []rechargeBillRegionBucketDTO{}})
return
}
if source, ok := h.billSources[appCode]; ok {
query := legacyRechargeBillQuery{
Keyword: options.Keyword,
RechargeType: strings.TrimSpace(firstQuery(c, "recharge_type", "rechargeType")),
PaidState: strings.TrimSpace(firstQuery(c, "paid_state", "paidState")),
RegionID: queryInt64(c, "region_id", "regionId"),
StartAtMS: startAtMS,
EndAtMS: endAtMS,
}
overview := rechargeBillOverviewDTO{Daily: []rechargeBillDailyBucketDTO{}, Regions: []rechargeBillRegionBucketDTO{}}
if userFilter.IsEmpty() && sellerFilter.IsEmpty() {
var err error
overview, err = source.Overview(c.Request.Context(), query, tzOffsetMinutes)
if err != nil {
response.ServerError(c, "获取财务概览失败")
return
}
}
if err := h.applyFinanceCoinSellerOverview(c.Request.Context(), appCode, financeCoinSellerRechargeQuery{
RechargeType: strings.TrimSpace(firstQuery(c, "recharge_type", "rechargeType")),
Status: options.Status,
PaidState: query.PaidState,
Keyword: options.Keyword,
RegionID: query.RegionID,
StartAtMS: startAtMS,
EndAtMS: endAtMS,
TzOffsetMinutes: tzOffsetMinutes,
TargetUserID: sellerUserID,
HasUserOnlyFilter: userID > 0 && sellerUserID == 0,
}, &overview); err != nil {
response.ServerError(c, "获取财务概览失败")
return
}
h.fillApprovedWithdrawalStats(&overview, appCode, startAtMS, endAtMS)
response.OK(c, overview)
return
}
overview := rechargeBillOverviewDTO{
Daily: []rechargeBillDailyBucketDTO{},
Regions: []rechargeBillRegionBucketDTO{},
}
financeQuery := financeCoinSellerRechargeQuery{
RechargeType: strings.TrimSpace(firstQuery(c, "recharge_type", "rechargeType")),
Status: options.Status,
@ -136,20 +136,157 @@ func (h *Handler) GetRechargeBillOverview(c *gin.Context) {
RequestID: middleware.CurrentRequestID(c),
UserID: userID,
}
if financeWalletOverviewSupportsOrdinaryFilters(financeQuery) {
resp, err := h.wallet.GetRechargeBillOverview(c.Request.Context(), &walletv1.GetRechargeBillOverviewRequest{
RequestId: middleware.CurrentRequestID(c),
// HTTP 页面和 Social BI 都走同一个装配函数;后续口径调整只允许在这里发生一次。
overview, err := h.loadFinanceRechargeOverview(c.Request.Context(), appCode, financeQuery, userFilter.IsEmpty() && sellerFilter.IsEmpty(), true)
if err != nil {
writeWalletError(c, err, "获取财务概览失败")
return
}
h.fillApprovedWithdrawalStats(&overview, appCode, startAtMS, endAtMS)
response.OK(c, overview)
}
// FinanceRechargeOverview 暴露 Finance 页面自身使用的充值聚合,供 Social BI 复用。
// 默认只复用 Finance 已经返回的全局逐日与区域总额;显式区域筛选才按区域补逐日渠道。
func (h *Handler) FinanceRechargeOverview(ctx context.Context, query FinanceRechargeOverviewQuery) (FinanceRechargeOverview, error) {
appCode := appctx.Normalize(query.AppCode)
if appCode == "" {
return FinanceRechargeOverview{}, fmt.Errorf("app_code is required")
}
tzOffsetMinutes := query.TZOffsetMinutes
if tzOffsetMinutes == 0 {
tzOffsetMinutes = 480
}
regionIDs := uniquePositiveInt64s(query.ScopeRegionIDs)
baseQuery := financeCoinSellerRechargeQuery{
StartAtMS: query.StartAtMS,
EndAtMS: query.EndAtMS,
TzOffsetMinutes: tzOffsetMinutes,
}
if len(regionIDs) > 0 {
regions, err := h.loadFinanceRechargeRegions(ctx, appCode, baseQuery, regionIDs)
if err != nil {
return FinanceRechargeOverview{}, err
}
return FinanceRechargeOverview{Daily: mergeFinanceRechargeRegionDaily(regions), Regions: regions}, nil
}
overview, err := h.loadFinanceRechargeOverview(ctx, appCode, baseQuery, true, query.IncludeRegions)
if err != nil {
return FinanceRechargeOverview{}, err
}
result := FinanceRechargeOverview{Daily: financeRechargeDailyBuckets(overview.Daily)}
if !query.IncludeRegions {
return result, nil
}
for _, region := range overview.Regions {
if region.RegionID <= 0 {
continue
}
result.Regions = append(result.Regions, FinanceRechargeRegionOverview{
RegionID: region.RegionID,
TotalUSDMinor: region.UsdMinorAmount,
})
}
sort.Slice(result.Regions, func(i, j int) bool { return result.Regions[i].RegionID < result.Regions[j].RegionID })
return result, nil
}
func (h *Handler) loadFinanceRechargeRegions(ctx context.Context, appCode string, baseQuery financeCoinSellerRechargeQuery, regionIDs []int64) ([]FinanceRechargeRegionOverview, error) {
type regionResult struct {
region FinanceRechargeRegionOverview
err error
}
results := make(chan regionResult, len(regionIDs))
semaphore := make(chan struct{}, 4)
var wg sync.WaitGroup
for _, regionID := range uniquePositiveInt64s(regionIDs) {
regionID := regionID
wg.Add(1)
go func() {
defer wg.Done()
semaphore <- struct{}{}
defer func() { <-semaphore }()
regionQuery := baseQuery
regionQuery.RegionID = regionID
// 单区域默认趋势已经同时返回 Google/三方渠道;这里不再额外查询两次渠道区域分布。
overview, err := h.loadFinanceRechargeOverview(ctx, appCode, regionQuery, true, false)
daily := financeRechargeDailyBuckets(overview.Daily)
results <- regionResult{
region: FinanceRechargeRegionOverview{
RegionID: regionID,
TotalUSDMinor: financeRechargeDailyTotal(daily),
DailyAvailable: true,
Daily: daily,
},
err: err,
}
}()
}
wg.Wait()
close(results)
regions := make([]FinanceRechargeRegionOverview, 0, len(regionIDs))
for result := range results {
if result.err != nil {
return nil, result.err
}
regions = append(regions, result.region)
}
sort.Slice(regions, func(i, j int) bool { return regions[i].RegionID < regions[j].RegionID })
return regions, nil
}
func financeRechargeDailyTotal(rows []FinanceRechargeDailyBucket) int64 {
var total int64
for _, row := range rows {
total += row.GoogleUSDMinor + row.ThirdPartyUSDMinor + row.CoinSellerUSDMinor
}
return total
}
// loadFinanceRechargeOverview 是 Finance HTTP 页面与模块内复用入口的唯一装配路径。
// ordinary 与币商统计仍分别由 wallet/legacy source 和 financeCoinSellerRechargeStats 提供,禁止回退旧库存币商口径。
func (h *Handler) loadFinanceRechargeOverview(ctx context.Context, appCode string, query financeCoinSellerRechargeQuery, includeOrdinary bool, includeRegions bool) (rechargeBillOverviewDTO, error) {
overview := rechargeBillOverviewDTO{Daily: []rechargeBillDailyBucketDTO{}, Regions: []rechargeBillRegionBucketDTO{}}
if source, ok := h.billSources[appCode]; ok {
if includeOrdinary {
var err error
overview, err = source.Overview(ctx, legacyRechargeBillQuery{
Keyword: query.Keyword,
RechargeType: query.RechargeType,
PaidState: query.PaidState,
RegionID: query.RegionID,
StartAtMS: query.StartAtMS,
EndAtMS: query.EndAtMS,
}, query.TzOffsetMinutes)
if err != nil {
return rechargeBillOverviewDTO{}, err
}
}
if err := h.applyFinanceCoinSellerOverview(ctx, appCode, query, &overview); err != nil {
return rechargeBillOverviewDTO{}, err
}
return overview, nil
}
if includeOrdinary && financeWalletOverviewSupportsOrdinaryFilters(query) {
if h.wallet == nil {
return rechargeBillOverviewDTO{}, fmt.Errorf("wallet recharge overview is not configured")
}
resp, err := h.wallet.GetRechargeBillOverview(ctx, &walletv1.GetRechargeBillOverviewRequest{
RequestId: query.RequestID,
AppCode: appCode,
RegionId: queryInt64(c, "region_id", "regionId"),
RechargeType: financeQuery.RechargeType,
Status: options.Status,
StartAtMs: startAtMS,
EndAtMs: endAtMS,
TzOffsetMinutes: tzOffsetMinutes,
RegionId: query.RegionID,
RechargeType: query.RechargeType,
Status: query.Status,
StartAtMs: query.StartAtMS,
EndAtMs: query.EndAtMS,
TzOffsetMinutes: query.TzOffsetMinutes,
})
if err != nil {
writeWalletError(c, err, "获取财务概览失败")
return
return rechargeBillOverviewDTO{}, err
}
overview.Daily = make([]rechargeBillDailyBucketDTO, 0, len(resp.GetDaily()))
for _, bucket := range resp.GetDaily() {
@ -179,20 +316,71 @@ func (h *Handler) GetRechargeBillOverview(c *gin.Context) {
overview.Withdrawal.TransferUsdMinor = transfer.GetTransferUsdMinor()
}
}
if normalizedRechargeType(financeQuery.RechargeType) == "" {
ordinaryRegions, err := h.walletOrdinaryRechargeOverviewRegions(c.Request.Context(), appCode, financeQuery)
if includeOrdinary && includeRegions && normalizedRechargeType(query.RechargeType) == "" {
ordinaryRegions, err := h.walletOrdinaryRechargeOverviewRegions(ctx, appCode, query)
if err != nil {
writeWalletError(c, err, "获取财务概览失败")
return
return rechargeBillOverviewDTO{}, err
}
overview.Regions = h.fillFinanceCoinSellerRegionNames(c.Request.Context(), appCode, ordinaryRegions)
overview.Regions = h.fillFinanceCoinSellerRegionNames(ctx, appCode, ordinaryRegions)
}
if err := h.applyFinanceCoinSellerOverview(c.Request.Context(), appCode, financeQuery, &overview); err != nil {
response.ServerError(c, "获取财务概览失败")
return
if err := h.applyFinanceCoinSellerOverview(ctx, appCode, query, &overview); err != nil {
return rechargeBillOverviewDTO{}, err
}
h.fillApprovedWithdrawalStats(&overview, appCode, startAtMS, endAtMS)
response.OK(c, overview)
return overview, nil
}
func financeRechargeDailyBuckets(rows []rechargeBillDailyBucketDTO) []FinanceRechargeDailyBucket {
out := make([]FinanceRechargeDailyBucket, 0, len(rows))
for _, row := range rows {
if strings.TrimSpace(row.Date) == "" {
continue
}
out = append(out, FinanceRechargeDailyBucket{
Date: row.Date,
GoogleUSDMinor: row.GoogleUsdMinor,
ThirdPartyUSDMinor: row.ThirdPartyUsdMinor,
CoinSellerUSDMinor: row.CoinSellerUsdMinor,
})
}
sort.Slice(out, func(i, j int) bool { return out[i].Date < out[j].Date })
return out
}
func mergeFinanceRechargeRegionDaily(regions []FinanceRechargeRegionOverview) []FinanceRechargeDailyBucket {
byDay := map[string]FinanceRechargeDailyBucket{}
for _, region := range regions {
for _, row := range region.Daily {
current := byDay[row.Date]
current.Date = row.Date
current.GoogleUSDMinor += row.GoogleUSDMinor
current.ThirdPartyUSDMinor += row.ThirdPartyUSDMinor
current.CoinSellerUSDMinor += row.CoinSellerUSDMinor
byDay[row.Date] = current
}
}
out := make([]FinanceRechargeDailyBucket, 0, len(byDay))
for _, row := range byDay {
out = append(out, row)
}
sort.Slice(out, func(i, j int) bool { return out[i].Date < out[j].Date })
return out
}
func uniquePositiveInt64s(values []int64) []int64 {
seen := map[int64]struct{}{}
out := make([]int64, 0, len(values))
for _, value := range values {
if value <= 0 {
continue
}
if _, ok := seen[value]; ok {
continue
}
seen[value] = struct{}{}
out = append(out, value)
}
sort.Slice(out, func(i, j int) bool { return out[i] < out[j] })
return out
}
func (h *Handler) requireLegacyCoinSellerDayRange(c *gin.Context, query legacyRechargeBillQuery, tzOffsetMinutes int32) bool {

View File

@ -129,7 +129,12 @@ func newRoomOutboxConsumer(cfg config.Config, services *serviceBundle) (*rocketm
if err != nil {
return nil, err
}
if err := consumer.Subscribe(cfg.RocketMQ.RoomOutbox.Topic, roommq.TagRoomOutboxEvent, func(ctx context.Context, message rocketmqx.ConsumedMessage) error {
roomTagExpression, err := roommq.LegacyCompatibleTagExpression(activityRoomEventTypes()...)
if err != nil {
_ = consumer.Shutdown()
return nil, err
}
if err := consumer.Subscribe(cfg.RocketMQ.RoomOutbox.Topic, roomTagExpression, func(ctx context.Context, message rocketmqx.ConsumedMessage) error {
envelope, _, err := roommq.DecodeRoomOutboxMessage(message.Body)
if err != nil {
return err
@ -163,6 +168,18 @@ func newRoomOutboxConsumer(cfg config.Config, services *serviceBundle) (*rocketm
return consumer, nil
}
func activityRoomEventTypes() []string {
// 六个投影共用同一 consumer group因此 selector 必须取其事件集合并集。
// RoomGiftSent 驱动增长/任务/周星/流水,另外三个事件只驱动播报;其他房间
// 事实由 Broker 过滤,避免一个送礼高峰放大成所有活动 handler 的无效调用。
return []string{
roommq.EventTypeRoomGiftSent,
roommq.EventTypeRoomPasswordChanged,
roommq.EventTypeRoomRocketIgnited,
roommq.EventTypeRoomRocketRewardGranted,
}
}
func newUserOutboxConsumer(cfg config.Config, broadcastSvc *broadcastservice.Service) (*rocketmqx.Consumer, error) {
consumer, err := rocketmqx.NewConsumer(userOutboxConsumerConfig(cfg.RocketMQ))
if err != nil {

View File

@ -1,8 +1,10 @@
package app
import (
"strings"
"testing"
"hyapp/pkg/roommq"
"hyapp/services/activity-service/internal/config"
)
@ -19,6 +21,27 @@ func TestRoomOutboxConsumerConfigSerializesCompositeProjection(t *testing.T) {
}
}
func TestActivityRoomSelectorKeepsLegacyAndOnlyRelevantTypedEvents(t *testing.T) {
expression, err := roommq.LegacyCompatibleTagExpression(activityRoomEventTypes()...)
if err != nil {
t.Fatalf("LegacyCompatibleTagExpression failed: %v", err)
}
for _, required := range []string{
roommq.TagRoomOutboxEvent,
roommq.EventTypeRoomGiftSent,
roommq.EventTypeRoomPasswordChanged,
roommq.EventTypeRoomRocketIgnited,
roommq.EventTypeRoomRocketRewardGranted,
} {
if !strings.Contains(expression, required) {
t.Fatalf("activity room selector %q misses %q", expression, required)
}
}
if strings.Contains(expression, roommq.EventTypeRoomMicChanged) {
t.Fatalf("activity room selector must not receive mic events: %q", expression)
}
}
func TestVIPRebateNoticeConsumerDoesNotReplayHistoricalWalletEvents(t *testing.T) {
cfg := config.Default().RocketMQ
cfg.WalletOutbox.VIPRebateNoticeConsumerGroup = "activity-vip-rebate-notice-test"

View File

@ -25,6 +25,8 @@ cors:
- "X-Bundle-ID"
- "X-App-Platform"
- "X-Platform"
- "X-App-Version"
- "X-App-Build-Number"
expose_headers: ["X-Request-ID"]
allow_credentials: true
max_age_sec: 600
@ -86,6 +88,26 @@ auth_rate_limit:
display_user_id_ip_limit: 10
display_user_id_limit: 50
session_id_ip_limit: 30
gift_capacity_limit:
# Docker 多 gateway 副本共享 Redis各聚合层在自己的 Cluster slot 内原子扣减,用户层同时占用在途槽。
enabled: true
redis_addr: "redis:6379"
redis_password: ""
redis_db: 0
key_prefix: "gateway:gift:capacity:"
user_bucket_capacity: 5000
user_refill_tokens_per_second: 500
room_bucket_capacity: 20000
room_refill_tokens_per_second: 5000
app_bucket_capacity: 100000
app_refill_tokens_per_second: 25000
max_targets_per_request: 30
max_gift_count_per_request: 999
max_gift_units_per_request: 5000
max_in_flight: 2
in_flight_lease: "10s"
redis_operation_timeout: "500ms"
request_timeout: "5s"
login_risk:
enabled: true
redis_addr: "redis:6379"

View File

@ -29,6 +29,8 @@ cors:
- "X-Bundle-ID"
- "X-App-Platform"
- "X-Platform"
- "X-App-Version"
- "X-App-Build-Number"
expose_headers: ["X-Request-ID"]
allow_credentials: true
max_age_sec: 600
@ -90,6 +92,27 @@ auth_rate_limit:
display_user_id_ip_limit: 10
display_user_id_limit: 50
session_id_ip_limit: 30
gift_capacity_limit:
# 所有生产 gateway 节点必须共享同一 Redis才能让 app:user:room 令牌和 app:user 并发槽全局生效。
enabled: true
redis_addr: "TENCENT_REDIS_HOST:6379"
redis_password: "TENCENT_REDIS_PASSWORD"
redis_db: 0
key_prefix: "gateway:gift:capacity:"
# 按 gift_count*去重目标数计费;生产值需结合幸运礼物 p99 和 Broker SLO 灰度校准。
user_bucket_capacity: 5000
user_refill_tokens_per_second: 500
room_bucket_capacity: 20000
room_refill_tokens_per_second: 5000
app_bucket_capacity: 100000
app_refill_tokens_per_second: 25000
max_targets_per_request: 30
max_gift_count_per_request: 999
max_gift_units_per_request: 5000
max_in_flight: 2
in_flight_lease: "10s"
redis_operation_timeout: "500ms"
request_timeout: "5s"
login_risk:
enabled: true
redis_addr: "TENCENT_REDIS_HOST:6379"

View File

@ -25,6 +25,8 @@ cors:
- "X-Bundle-ID"
- "X-App-Platform"
- "X-Platform"
- "X-App-Version"
- "X-App-Build-Number"
expose_headers: ["X-Request-ID"]
allow_credentials: true
max_age_sec: 600
@ -86,6 +88,27 @@ auth_rate_limit:
display_user_id_ip_limit: 10
display_user_id_limit: 50
session_id_ip_limit: 30
gift_capacity_limit:
# 送礼按 gift_count*去重 target_count 扣令牌;这同时覆盖多人账务和幸运礼物逐份开奖成本。
enabled: true
redis_addr: "127.0.0.1:13379"
redis_password: ""
redis_db: 0
key_prefix: "gateway:gift:capacity:"
# 桶容量至少容纳一个合法最大工作量;持续速率在极端 999 数量后施加自然冷却。
user_bucket_capacity: 5000
user_refill_tokens_per_second: 500
room_bucket_capacity: 20000
room_refill_tokens_per_second: 5000
app_bucket_capacity: 100000
app_refill_tokens_per_second: 25000
max_targets_per_request: 30
max_gift_count_per_request: 999
max_gift_units_per_request: 5000
max_in_flight: 2
in_flight_lease: "10s"
redis_operation_timeout: "500ms"
request_timeout: "5s"
login_risk:
enabled: true
redis_addr: "127.0.0.1:13379"

View File

@ -25,6 +25,7 @@ import (
"hyapp/services/gateway-service/internal/financewithdrawal"
"hyapp/services/gateway-service/internal/healthcheck"
httptransport "hyapp/services/gateway-service/internal/transport/http"
"hyapp/services/gateway-service/internal/transport/http/giftlimit"
)
// App 装配 gateway 的 HTTP 入口。
@ -293,6 +294,12 @@ func New(cfg config.Config) (*App, error) {
return nil, err
}
redisClose = joinClose(redisClose, authRedisClose)
giftCapacityRedisClose, err := configureGiftCapacityLimit(handler, cfg.GiftCapacityLimit)
if err != nil {
closeGatewayDeps(redisClose)
return nil, err
}
redisClose = joinClose(redisClose, giftCapacityRedisClose)
loginRiskRedisClose, err := configureLoginRisk(handler, cfg.LoginRisk, loginRiskConfig)
if err != nil {
closeGatewayDeps(redisClose)
@ -417,6 +424,41 @@ func configureAuthRateLimit(handler *httptransport.Handler, cfg config.AuthRateL
return redisClient.Close, nil
}
func configureGiftCapacityLimit(handler *httptransport.Handler, cfg config.GiftCapacityLimitConfig) (func() error, error) {
handler.SetGiftRequestLimits(cfg.MaxGiftCountPerRequest, cfg.MaxGiftUnitsPerRequest)
if !cfg.Enabled {
handler.SetGiftCapacityProtection(false, cfg.RequestTimeout, cfg.MaxTargetsPerRequest, nil)
return nil, nil
}
redisAddr := strings.TrimSpace(cfg.RedisAddr)
if redisAddr == "" {
return nil, errors.New("gift capacity limit redis_addr is required")
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
redisClient, err := giftlimit.NewRedisClient(ctx, redisAddr, cfg.RedisPassword, cfg.RedisDB)
if err != nil {
return nil, fmt.Errorf("connect gift capacity limit redis: %w", err)
}
runtimeConfig := giftlimit.Config{
Enabled: true,
KeyPrefix: cfg.KeyPrefix,
UserBucketCapacity: cfg.UserBucketCapacity,
UserRefillTokensPerSecond: cfg.UserRefillTokensPerSecond,
RoomBucketCapacity: cfg.RoomBucketCapacity,
RoomRefillTokensPerSecond: cfg.RoomRefillTokensPerSecond,
AppBucketCapacity: cfg.AppBucketCapacity,
AppRefillTokensPerSecond: cfg.AppRefillTokensPerSecond,
MaxInFlight: cfg.MaxInFlight,
InFlightLease: cfg.InFlightLease,
RedisOperationTimeout: cfg.RedisOperationTimeout,
}
// 与 public auth 频控相同,生产送礼保护必须使用共享 Redis进程内计数无法约束多副本总流量。
handler.SetGiftCapacityProtection(true, cfg.RequestTimeout, cfg.MaxTargetsPerRequest, giftlimit.NewRedisLimiter(redisClient, runtimeConfig))
return redisClient.Close, nil
}
func configureUserLeaderboard(handler *httptransport.Handler, cfg config.LeaderboardConfig) (func() error, error) {
redisAddr := strings.TrimSpace(cfg.RedisAddr)
if redisAddr == "" {

View File

@ -30,3 +30,22 @@ func TestConfigureAuthRateLimitDisabledDoesNotRequireRedis(t *testing.T) {
t.Fatal("disabled auth rate limit must not allocate redis client")
}
}
func TestConfigureGiftCapacityLimitRequiresRedisAddrWhenEnabled(t *testing.T) {
handler := httptransport.NewHandler(nil)
_, err := configureGiftCapacityLimit(handler, config.GiftCapacityLimitConfig{Enabled: true})
if err == nil || !strings.Contains(err.Error(), "redis_addr is required") {
t.Fatalf("enabled gift capacity limit must require redis_addr, err=%v", err)
}
}
func TestConfigureGiftCapacityLimitDisabledDoesNotRequireRedis(t *testing.T) {
handler := httptransport.NewHandler(nil)
closeRedis, err := configureGiftCapacityLimit(handler, config.GiftCapacityLimitConfig{Enabled: false})
if err != nil {
t.Fatalf("disabled gift capacity limit should not connect redis: %v", err)
}
if closeRedis != nil {
t.Fatal("disabled gift capacity limit must not allocate redis client")
}
}

View File

@ -0,0 +1,179 @@
package appconfig
import (
"context"
"encoding/json"
"errors"
"fmt"
"strings"
"hyapp/pkg/giftlimits"
)
const (
giftComboConfigGroup = "room-gift-combo"
giftComboConfigKey = "default"
)
const getGiftComboConfigSQL = `
SELECT app_code, COALESCE(value, ''), is_deleted, updated_at_ms
FROM admin_app_configs
WHERE app_code IN ('', ?) AND ` + "`group`" + ` = ? AND ` + "`key`" + ` = ?
ORDER BY app_code ASC`
// GiftComboConfig 是礼物面板下发给 Flutter 的运行时策略。
// 这里仅控制客户端如何封批和施加背压;账务数量、幂等和容量上限仍由 owner service 强制执行,
// 因而旧客户端、篡改客户端或缓存了旧配置的客户端都不能绕过服务端保护。
type GiftComboConfig struct {
Enabled bool `json:"enabled"`
APIVersion int32 `json:"api_version"`
WindowMS int32 `json:"window_ms"`
MaxGiftCountPerBatch int32 `json:"max_gift_count_per_batch"`
MaxGiftUnitsPerBatch int32 `json:"max_gift_units_per_batch"`
MaxInFlight int32 `json:"max_in_flight"`
MaxPendingBatches int32 `json:"max_pending_batches"`
RetryMaxAttempts int32 `json:"retry_max_attempts"`
RetryBaseDelayMS int32 `json:"retry_base_delay_ms"`
RolloutBasisPoints int32 `json:"rollout_basis_points"`
UpdatedAtMS int64 `json:"updated_at_ms"`
}
type storedGiftComboConfig struct {
appCode string
value string
isDeleted bool
updatedAtMS int64
}
// DefaultGiftComboConfig keeps the feature usable when no admin row exists. A production kill switch
// is an explicit scoped row with enabled=false, so a transient config read failure is never silently
// converted into a different batching policy by this method.
func DefaultGiftComboConfig() GiftComboConfig {
return GiftComboConfig{
Enabled: true,
APIVersion: 2,
WindowMS: 300,
MaxGiftCountPerBatch: 999,
MaxGiftUnitsPerBatch: 5_000,
MaxInFlight: 2,
MaxPendingBatches: 20,
RetryMaxAttempts: 5,
RetryBaseDelayMS: 250,
// 缺少后台配置时必须保持 0% 灰度。发布代码不能等价于全量打开财务主链路;
// 运维确认 V2、Saga 和 MQ 指标后,再按 App 显式提高该值。
RolloutBasisPoints: 0,
}
}
// GiftComboConfig returns the app-scoped remote policy. A scoped tombstone intentionally suppresses
// the global baseline and falls back to code defaults, matching the admin_app_configs overlay contract.
func (r *MySQLReader) GiftComboConfig(ctx context.Context, appCode string) (GiftComboConfig, error) {
if r == nil || r.db == nil {
return GiftComboConfig{}, errors.New("app config reader is not configured")
}
appCode = normalizeAppCode(appCode)
rows, err := r.db.QueryContext(ctx, getGiftComboConfigSQL, appCode, giftComboConfigGroup, giftComboConfigKey)
if err != nil {
return GiftComboConfig{}, err
}
defer rows.Close()
var baseline *storedGiftComboConfig
var scoped *storedGiftComboConfig
for rows.Next() {
var item storedGiftComboConfig
if err := rows.Scan(&item.appCode, &item.value, &item.isDeleted, &item.updatedAtMS); err != nil {
return GiftComboConfig{}, err
}
if strings.EqualFold(strings.TrimSpace(item.appCode), appCode) {
copy := item
scoped = &copy
continue
}
copy := item
baseline = &copy
}
if err := rows.Err(); err != nil {
return GiftComboConfig{}, err
}
selected := baseline
if scoped != nil {
if scoped.isDeleted {
return DefaultGiftComboConfig(), nil
}
selected = scoped
}
if selected == nil || selected.isDeleted || strings.TrimSpace(selected.value) == "" {
return DefaultGiftComboConfig(), nil
}
config, err := ParseGiftComboConfig(selected.value)
if err != nil {
return GiftComboConfig{}, fmt.Errorf("parse gift combo config for app %s: %w", appCode, err)
}
config.UpdatedAtMS = selected.updatedAtMS
return config, nil
}
// ParseGiftComboConfig overlays a stored JSON object on safe defaults. This allows newly introduced
// fields to acquire defaults while old admin rows remain valid during rolling upgrades.
func ParseGiftComboConfig(raw string) (GiftComboConfig, error) {
config := DefaultGiftComboConfig()
if err := json.Unmarshal([]byte(strings.TrimSpace(raw)), &config); err != nil {
return GiftComboConfig{}, err
}
if config.APIVersion == 1 {
// 早期灰度配置曾把 1 当作客户端降级开关。读取时一次性提升为当前 V2 契约,避免
// 已有 admin_app_configs 行让礼物面板降级失败;新的配置写入仍只能提交 2。
config.APIVersion = 2
}
return NormalizeGiftComboConfig(config)
}
// NormalizeGiftComboConfig rejects policies that could disable client backpressure or exceed the V2
// contract. Validation is deliberately shared with admin writes, but callers still enforce server caps.
func NormalizeGiftComboConfig(config GiftComboConfig) (GiftComboConfig, error) {
if config.APIVersion == 0 {
config.APIVersion = 2
}
if config.APIVersion != 2 {
// api_version 是当前客户端契约标记,不是远程降级开关。旧 Flutter 自己固定调用 V1
// 新 Flutter 必须始终使用具备完整幂等结果重放语义的 V2。
return GiftComboConfig{}, fmt.Errorf("api_version must be 2")
}
if config.WindowMS < 100 || config.WindowMS > 1_000 {
return GiftComboConfig{}, fmt.Errorf("window_ms must be between 100 and 1000")
}
if config.MaxGiftCountPerBatch < 1 || config.MaxGiftCountPerBatch > 999 {
return GiftComboConfig{}, fmt.Errorf("max_gift_count_per_batch must be between 1 and 999")
}
if config.MaxGiftUnitsPerBatch < 1 || int64(config.MaxGiftUnitsPerBatch) > giftlimits.MaxTotalGiftUnits {
return GiftComboConfig{}, fmt.Errorf("max_gift_units_per_batch must be between 1 and %d", giftlimits.MaxTotalGiftUnits)
}
if config.MaxInFlight < 1 || config.MaxInFlight > 2 {
return GiftComboConfig{}, fmt.Errorf("max_in_flight must be between 1 and 2")
}
if config.MaxPendingBatches < 1 || config.MaxPendingBatches > 50 {
return GiftComboConfig{}, fmt.Errorf("max_pending_batches must be between 1 and 50")
}
if config.MaxPendingBatches < config.MaxInFlight {
// Flutter allocates in-flight work from the same bounded batch queue. A smaller queue cannot
// represent the advertised concurrency and would make the client silently rewrite policy.
return GiftComboConfig{}, fmt.Errorf("max_pending_batches must be greater than or equal to max_in_flight")
}
if config.RetryMaxAttempts < 1 || config.RetryMaxAttempts > 10 {
return GiftComboConfig{}, fmt.Errorf("retry_max_attempts must be between 1 and 10")
}
if config.RetryBaseDelayMS < 100 || config.RetryBaseDelayMS > 5_000 {
return GiftComboConfig{}, fmt.Errorf("retry_base_delay_ms must be between 100 and 5000")
}
if config.RolloutBasisPoints < 0 || config.RolloutBasisPoints > 10_000 {
return GiftComboConfig{}, fmt.Errorf("rollout_basis_points must be between 0 and 10000")
}
return config, nil
}
var _ interface {
GiftComboConfig(context.Context, string) (GiftComboConfig, error)
} = (*MySQLReader)(nil)

View File

@ -0,0 +1,49 @@
package appconfig
import "testing"
func TestParseGiftComboConfigOverlaysDefaultsAndPreservesKillSwitch(t *testing.T) {
config, err := ParseGiftComboConfig(`{"enabled":false,"rollout_basis_points":2500}`)
if err != nil {
t.Fatalf("ParseGiftComboConfig failed: %v", err)
}
if config.Enabled || config.RolloutBasisPoints != 2500 || config.APIVersion != 2 || config.WindowMS != 300 || config.MaxGiftCountPerBatch != 999 || config.MaxGiftUnitsPerBatch != 5_000 || config.MaxInFlight != 2 || config.RetryBaseDelayMS != 250 {
t.Fatalf("partial remote config did not inherit safe defaults: %+v", config)
}
}
func TestParseGiftComboConfigPromotesStoredV1MarkerWithoutDowngradingClient(t *testing.T) {
config, err := ParseGiftComboConfig(`{"api_version":1,"enabled":false}`)
if err != nil {
t.Fatalf("legacy stored config must remain readable: %v", err)
}
if config.APIVersion != 2 || config.Enabled {
t.Fatalf("stored V1 marker must become a V2 micro-batch kill switch: %+v", config)
}
}
func TestDefaultGiftComboConfigRequiresExplicitRollout(t *testing.T) {
config := DefaultGiftComboConfig()
if config.RolloutBasisPoints != 0 {
t.Fatalf("code deployment must not enable micro-batching rollout implicitly: %+v", config)
}
}
func TestNormalizeGiftComboConfigRejectsUnboundedClientPolicy(t *testing.T) {
tests := []GiftComboConfig{
{APIVersion: 1, WindowMS: 300, MaxGiftCountPerBatch: 999, MaxGiftUnitsPerBatch: 5_000, MaxInFlight: 2, MaxPendingBatches: 20, RetryMaxAttempts: 5, RetryBaseDelayMS: 250, RolloutBasisPoints: 10_000},
{APIVersion: 3, WindowMS: 300, MaxGiftCountPerBatch: 999, MaxGiftUnitsPerBatch: 5_000, MaxInFlight: 2, MaxPendingBatches: 20, RetryMaxAttempts: 5, RetryBaseDelayMS: 250, RolloutBasisPoints: 10_000},
{APIVersion: 2, WindowMS: 99, MaxGiftCountPerBatch: 999, MaxGiftUnitsPerBatch: 5_000, MaxInFlight: 2, MaxPendingBatches: 20, RetryMaxAttempts: 5, RetryBaseDelayMS: 250, RolloutBasisPoints: 10_000},
{APIVersion: 2, WindowMS: 300, MaxGiftCountPerBatch: 1_000, MaxGiftUnitsPerBatch: 5_000, MaxInFlight: 2, MaxPendingBatches: 20, RetryMaxAttempts: 5, RetryBaseDelayMS: 250, RolloutBasisPoints: 10_000},
{APIVersion: 2, WindowMS: 300, MaxGiftCountPerBatch: 999, MaxGiftUnitsPerBatch: 5_001, MaxInFlight: 2, MaxPendingBatches: 20, RetryMaxAttempts: 5, RetryBaseDelayMS: 250, RolloutBasisPoints: 10_000},
{APIVersion: 2, WindowMS: 300, MaxGiftCountPerBatch: 999, MaxGiftUnitsPerBatch: 5_000, MaxInFlight: 3, MaxPendingBatches: 20, RetryMaxAttempts: 5, RetryBaseDelayMS: 250, RolloutBasisPoints: 10_000},
{APIVersion: 2, WindowMS: 300, MaxGiftCountPerBatch: 999, MaxGiftUnitsPerBatch: 5_000, MaxInFlight: 2, MaxPendingBatches: 0, RetryMaxAttempts: 5, RetryBaseDelayMS: 250, RolloutBasisPoints: 10_000},
{APIVersion: 2, WindowMS: 300, MaxGiftCountPerBatch: 999, MaxGiftUnitsPerBatch: 5_000, MaxInFlight: 2, MaxPendingBatches: 1, RetryMaxAttempts: 5, RetryBaseDelayMS: 250, RolloutBasisPoints: 10_000},
{APIVersion: 2, WindowMS: 300, MaxGiftCountPerBatch: 999, MaxGiftUnitsPerBatch: 5_000, MaxInFlight: 2, MaxPendingBatches: 20, RetryMaxAttempts: 5, RetryBaseDelayMS: 99, RolloutBasisPoints: 10_000},
}
for _, config := range tests {
if _, err := NormalizeGiftComboConfig(config); err == nil {
t.Fatalf("invalid policy was accepted: %+v", config)
}
}
}

View File

@ -8,6 +8,7 @@ import (
"time"
"hyapp/pkg/configx"
"hyapp/pkg/giftlimits"
"hyapp/pkg/grpcclient"
"hyapp/pkg/logx"
"hyapp/pkg/tencentim"
@ -31,6 +32,7 @@ type Config struct {
Statistics StatisticsConfig `yaml:"statistics"`
GRPCClient GRPCClientConfig `yaml:"grpc_client"`
AuthRateLimit AuthRateLimitConfig `yaml:"auth_rate_limit"`
GiftCapacityLimit GiftCapacityLimitConfig `yaml:"gift_capacity_limit"`
LoginRisk LoginRiskConfig `yaml:"login_risk"`
AppConfig AppConfigConfig `yaml:"app_config"`
Leaderboard LeaderboardConfig `yaml:"leaderboard"`
@ -191,6 +193,42 @@ type AuthRateLimitConfig struct {
SessionIDIPLimit int `yaml:"session_id_ip_limit"`
}
// GiftCapacityLimitConfig 控制送礼入口按真实目标成本执行的 Redis 容量保护。
type GiftCapacityLimitConfig struct {
// Enabled 为 false 时显式关闭保护本地、Docker 和生产模板均默认开启。
Enabled bool `yaml:"enabled"`
// RedisAddr/Password/DB 指向所有 gateway 节点共享的 Redis地址为空时复用 auth_rate_limit。
RedisAddr string `yaml:"redis_addr"`
RedisPassword string `yaml:"redis_password"`
RedisDB int `yaml:"redis_db"`
// KeyPrefix 隔离送礼令牌桶和在途租约,避免与登录频控共享计数空间。
KeyPrefix string `yaml:"key_prefix"`
// User/Room/App 三层都按 gift_count*去重 target_count 计费;用户层保护单账号,
// 房间层收敛多人叠加App 层兜底总抽奖/账务工作量。
UserBucketCapacity int `yaml:"user_bucket_capacity"`
UserRefillTokensPerSecond float64 `yaml:"user_refill_tokens_per_second"`
RoomBucketCapacity int `yaml:"room_bucket_capacity"`
RoomRefillTokensPerSecond float64 `yaml:"room_refill_tokens_per_second"`
AppBucketCapacity int `yaml:"app_bucket_capacity"`
AppRefillTokensPerSecond float64 `yaml:"app_refill_tokens_per_second"`
// MaxTargetsPerRequest 是协议入口硬上限,超过后直接 4xx不进入永远无法补足的大权重令牌重试。
MaxTargetsPerRequest int `yaml:"max_targets_per_request"`
// MaxGiftCountPerRequest 限制单目标礼物数量MaxGiftUnitsPerRequest 进一步限制
// gift_count*去重目标数,避免 999*全麦把幸运礼物随机/落库成本放大到不可控。
MaxGiftCountPerRequest int `yaml:"max_gift_count_per_request"`
MaxGiftUnitsPerRequest int `yaml:"max_gift_units_per_request"`
// MaxInFlight 是单个 app:user 在所有房间合计允许的并发送礼请求数。
MaxInFlight int `yaml:"max_in_flight"`
// InFlightLease 是 gateway 异常退出后并发槽自动释放的上限。
InFlightLease time.Duration `yaml:"in_flight_lease"`
// RedisOperationTimeout 是三层 Lua 的共同总预算,不是每层各自预算。
RedisOperationTimeout time.Duration `yaml:"redis_operation_timeout"`
// RequestTimeout 覆盖 profile、host 和 room gRPC 的完整编排lease 必须严格大于它并留安全余量。
RequestTimeout time.Duration `yaml:"request_timeout"`
}
const giftCapacityLeaseSafetyMargin = 2 * time.Second
// TencentIMConfig 描述 gateway 给客户端签发腾讯云 IM UserSig 的配置。
type TencentIMConfig struct {
// SDKAppID 是腾讯云 IM 控制台分配的应用 ID。
@ -303,12 +341,18 @@ func Default() Config {
"X-Bundle-ID",
"X-App-Platform",
"X-Platform",
"X-App-Version",
"X-App-Build-Number",
},
ExposeHeaders: []string{"X-Request-ID"},
AllowCredentials: true,
MaxAgeSec: 600,
},
JWTSecret: "gateway-dev-secret",
// `make run` deliberately uses in-code defaults when MQ is disabled, so
// this must remain identical to user-service's local JWT signing secret.
// A divergent default makes every freshly refreshed token fail gateway
// verification even though login itself appears successful.
JWTSecret: "dev-shared-secret",
RoomServiceAddr: "127.0.0.1:13001",
UserServiceAddr: "127.0.0.1:13005",
WalletServiceAddr: "127.0.0.1:13004",
@ -352,6 +396,24 @@ func Default() Config {
DisplayUserIDLimit: 50,
SessionIDIPLimit: 30,
},
GiftCapacityLimit: GiftCapacityLimitConfig{
Enabled: true,
// RedisAddr 故意留空,由 Normalize 复用 auth_rate_limit Redis旧 YAML 没有本 section 也能安全启动。
KeyPrefix: "gateway:gift:capacity:",
UserBucketCapacity: 5_000,
UserRefillTokensPerSecond: 500,
RoomBucketCapacity: 20_000,
RoomRefillTokensPerSecond: 5_000,
AppBucketCapacity: 100_000,
AppRefillTokensPerSecond: 25_000,
MaxTargetsPerRequest: 30,
MaxGiftCountPerRequest: int(giftlimits.MaxGiftCount),
MaxGiftUnitsPerRequest: int(giftlimits.MaxTotalGiftUnits),
MaxInFlight: 2,
InFlightLease: 10 * time.Second,
RedisOperationTimeout: 500 * time.Millisecond,
RequestTimeout: 5 * time.Second,
},
LoginRisk: LoginRiskConfig{
Enabled: true,
RedisAddr: "127.0.0.1:13379",
@ -427,7 +489,7 @@ func (cfg *Config) Normalize() error {
cfg.CORS.AllowedMethods = []string{"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"}
}
if len(cfg.CORS.AllowedHeaders) == 0 {
cfg.CORS.AllowedHeaders = []string{"Authorization", "Content-Type", "X-Request-ID", "X-App-Code", "X-HY-App-Code", "X-App-Package", "X-Package-Name", "X-App-Bundle-ID", "X-Bundle-ID", "X-App-Platform", "X-Platform"}
cfg.CORS.AllowedHeaders = []string{"Authorization", "Content-Type", "X-Request-ID", "X-App-Code", "X-HY-App-Code", "X-App-Package", "X-Package-Name", "X-App-Bundle-ID", "X-Bundle-ID", "X-App-Platform", "X-Platform", "X-App-Version", "X-App-Build-Number"}
}
if len(cfg.CORS.ExposeHeaders) == 0 {
cfg.CORS.ExposeHeaders = []string{"X-Request-ID"}
@ -519,6 +581,67 @@ func (cfg *Config) Normalize() error {
if cfg.LoginRisk.KeyPrefix == "" {
cfg.LoginRisk.KeyPrefix = "login_risk:ip:"
}
cfg.GiftCapacityLimit.RedisAddr = strings.TrimSpace(cfg.GiftCapacityLimit.RedisAddr)
cfg.GiftCapacityLimit.KeyPrefix = strings.TrimSpace(cfg.GiftCapacityLimit.KeyPrefix)
if cfg.GiftCapacityLimit.Enabled && cfg.GiftCapacityLimit.RedisAddr == "" {
// 送礼保护与 auth 频控具有同样的强一致入口语义,默认复用已部署的共享 Redis
// 但使用独立 key_prefix避免引入新的基础设施依赖。
cfg.GiftCapacityLimit.RedisAddr = strings.TrimSpace(cfg.AuthRateLimit.RedisAddr)
cfg.GiftCapacityLimit.RedisPassword = cfg.AuthRateLimit.RedisPassword
cfg.GiftCapacityLimit.RedisDB = cfg.AuthRateLimit.RedisDB
}
if cfg.GiftCapacityLimit.KeyPrefix == "" {
cfg.GiftCapacityLimit.KeyPrefix = "gateway:gift:capacity:"
}
if cfg.GiftCapacityLimit.UserBucketCapacity <= 0 {
cfg.GiftCapacityLimit.UserBucketCapacity = 5_000
}
if cfg.GiftCapacityLimit.UserRefillTokensPerSecond <= 0 {
cfg.GiftCapacityLimit.UserRefillTokensPerSecond = 500
}
if cfg.GiftCapacityLimit.RoomBucketCapacity <= 0 {
cfg.GiftCapacityLimit.RoomBucketCapacity = 20_000
}
if cfg.GiftCapacityLimit.RoomRefillTokensPerSecond <= 0 {
cfg.GiftCapacityLimit.RoomRefillTokensPerSecond = 5_000
}
if cfg.GiftCapacityLimit.AppBucketCapacity <= 0 {
cfg.GiftCapacityLimit.AppBucketCapacity = 100_000
}
if cfg.GiftCapacityLimit.AppRefillTokensPerSecond <= 0 {
cfg.GiftCapacityLimit.AppRefillTokensPerSecond = 25_000
}
if cfg.GiftCapacityLimit.MaxTargetsPerRequest <= 0 {
cfg.GiftCapacityLimit.MaxTargetsPerRequest = 30
}
if cfg.GiftCapacityLimit.MaxGiftCountPerRequest <= 0 {
cfg.GiftCapacityLimit.MaxGiftCountPerRequest = int(giftlimits.MaxGiftCount)
}
if cfg.GiftCapacityLimit.MaxGiftUnitsPerRequest <= 0 {
cfg.GiftCapacityLimit.MaxGiftUnitsPerRequest = int(giftlimits.MaxTotalGiftUnits)
}
if int64(cfg.GiftCapacityLimit.MaxGiftCountPerRequest) > giftlimits.MaxGiftCount || int64(cfg.GiftCapacityLimit.MaxGiftUnitsPerRequest) > giftlimits.MaxTotalGiftUnits {
return fmt.Errorf("gift_capacity_limit request limits exceed cross-service hard ceiling")
}
if cfg.GiftCapacityLimit.MaxInFlight <= 0 {
cfg.GiftCapacityLimit.MaxInFlight = 2
}
if cfg.GiftCapacityLimit.InFlightLease <= 0 {
cfg.GiftCapacityLimit.InFlightLease = 10 * time.Second
}
if cfg.GiftCapacityLimit.RedisOperationTimeout <= 0 {
cfg.GiftCapacityLimit.RedisOperationTimeout = 500 * time.Millisecond
}
if cfg.GiftCapacityLimit.RequestTimeout <= 0 {
cfg.GiftCapacityLimit.RequestTimeout = 5 * time.Second
}
if cfg.GiftCapacityLimit.UserBucketCapacity < cfg.GiftCapacityLimit.MaxGiftUnitsPerRequest || cfg.GiftCapacityLimit.RoomBucketCapacity < cfg.GiftCapacityLimit.MaxGiftUnitsPerRequest || cfg.GiftCapacityLimit.AppBucketCapacity < cfg.GiftCapacityLimit.MaxGiftUnitsPerRequest {
return fmt.Errorf("gift_capacity_limit bucket capacities must be >= max_gift_units_per_request")
}
if cfg.GiftCapacityLimit.InFlightLease <= cfg.GiftCapacityLimit.RequestTimeout+giftCapacityLeaseSafetyMargin {
// lease 从用户层获准时开始计时;严格大于 RPC 总预算和 2s 收尾余量,确保正常请求期间槽位不会提前复用。
return fmt.Errorf("gift_capacity_limit.in_flight_lease must exceed request_timeout by more than %s", giftCapacityLeaseSafetyMargin)
}
cfg.TencentCOS.SecretID = strings.TrimSpace(cfg.TencentCOS.SecretID)
cfg.TencentCOS.SecretKey = strings.TrimSpace(cfg.TencentCOS.SecretKey)
cfg.TencentCOS.BucketName = strings.TrimSpace(cfg.TencentCOS.BucketName)

View File

@ -22,7 +22,7 @@ func TestLoad(t *testing.T) {
if cfg.RoomServiceAddr != "127.0.0.1:13001" {
t.Fatalf("unexpected room addr: %s", cfg.RoomServiceAddr)
}
if !cfg.CORS.Enabled || !containsString(cfg.CORS.AllowedOrigins, "*") || !containsString(cfg.CORS.AllowedHeaders, "X-App-Code") {
if !cfg.CORS.Enabled || !containsString(cfg.CORS.AllowedOrigins, "*") || !containsString(cfg.CORS.AllowedHeaders, "X-App-Code") || !containsString(cfg.CORS.AllowedHeaders, "X-App-Version") || !containsString(cfg.CORS.AllowedHeaders, "X-App-Build-Number") {
t.Fatalf("unexpected cors config: %+v", cfg.CORS)
}
if cfg.GRPCClient.DefaultTimeout != 5*time.Second || cfg.GRPCClient.RetryMaxAttempts != 2 || len(cfg.GRPCClient.RetryableStatusCodes) != 1 || cfg.GRPCClient.RetryableStatusCodes[0] != "UNAVAILABLE" {
@ -40,6 +40,15 @@ func TestLoad(t *testing.T) {
if cfg.AuthRateLimit.KeyPrefix != "gateway:auth:rate:" {
t.Fatalf("unexpected auth rate key prefix: %q", cfg.AuthRateLimit.KeyPrefix)
}
if !cfg.GiftCapacityLimit.Enabled || cfg.GiftCapacityLimit.RedisAddr != "127.0.0.1:13379" || cfg.GiftCapacityLimit.KeyPrefix != "gateway:gift:capacity:" {
t.Fatalf("local gift capacity config mismatch: %+v", cfg.GiftCapacityLimit)
}
if cfg.GiftCapacityLimit.UserBucketCapacity != 5_000 || cfg.GiftCapacityLimit.UserRefillTokensPerSecond != 500 || cfg.GiftCapacityLimit.RoomBucketCapacity != 20_000 || cfg.GiftCapacityLimit.RoomRefillTokensPerSecond != 5_000 || cfg.GiftCapacityLimit.AppBucketCapacity != 100_000 || cfg.GiftCapacityLimit.AppRefillTokensPerSecond != 25_000 {
t.Fatalf("unexpected local gift capacity buckets: %+v", cfg.GiftCapacityLimit)
}
if cfg.GiftCapacityLimit.MaxTargetsPerRequest != 30 || cfg.GiftCapacityLimit.MaxGiftCountPerRequest != 999 || cfg.GiftCapacityLimit.MaxGiftUnitsPerRequest != 5_000 || cfg.GiftCapacityLimit.MaxInFlight != 2 || cfg.GiftCapacityLimit.InFlightLease != 10*time.Second || cfg.GiftCapacityLimit.RedisOperationTimeout != 500*time.Millisecond || cfg.GiftCapacityLimit.RequestTimeout != 5*time.Second {
t.Fatalf("unexpected local gift capacity policy: %+v", cfg.GiftCapacityLimit)
}
if !cfg.TencentRTC.Enabled || cfg.TencentRTC.SDKAppID == 0 || cfg.TencentRTC.SecretKey == "" {
t.Fatalf("local tencent rtc config must be explicit: %+v", cfg.TencentRTC)
}
@ -73,7 +82,7 @@ func TestLoadTencentExample(t *testing.T) {
if cfg.TencentIM.CallbackURL == "" {
t.Fatalf("callback_url template must be present")
}
if !cfg.CORS.Enabled || !containsString(cfg.CORS.AllowedOrigins, "https://h5.global-interaction.com") || cfg.CORS.MaxAgeSec != 600 {
if !cfg.CORS.Enabled || !containsString(cfg.CORS.AllowedOrigins, "https://h5.global-interaction.com") || !containsString(cfg.CORS.AllowedHeaders, "X-App-Version") || !containsString(cfg.CORS.AllowedHeaders, "X-App-Build-Number") || cfg.CORS.MaxAgeSec != 600 {
t.Fatalf("tencent cors template must be explicit: %+v", cfg.CORS)
}
if cfg.AppConfig.MySQLDSN == "" || !strings.Contains(cfg.AppConfig.MySQLDSN, "hyapp_admin") {
@ -88,6 +97,9 @@ func TestLoadTencentExample(t *testing.T) {
if !cfg.AuthRateLimit.Enabled || cfg.AuthRateLimit.RedisAddr == "" || cfg.AuthRateLimit.RedisPassword == "" {
t.Fatalf("tencent auth rate redis template must be explicit: %+v", cfg.AuthRateLimit)
}
if !cfg.GiftCapacityLimit.Enabled || cfg.GiftCapacityLimit.RedisAddr == "" || cfg.GiftCapacityLimit.RedisPassword == "" || cfg.GiftCapacityLimit.InFlightLease != 10*time.Second || cfg.GiftCapacityLimit.RequestTimeout != 5*time.Second || cfg.GiftCapacityLimit.MaxTargetsPerRequest != 30 {
t.Fatalf("tencent gift capacity template must be explicit: %+v", cfg.GiftCapacityLimit)
}
if !cfg.TencentRTC.Enabled || cfg.TencentRTC.SDKAppID == 0 || cfg.TencentRTC.SecretKey == "" {
t.Fatalf("tencent rtc template must be explicit: %+v", cfg.TencentRTC)
}
@ -102,6 +114,62 @@ func TestLoadTencentExample(t *testing.T) {
}
}
func TestLoadDockerGiftCapacityLimit(t *testing.T) {
cfg, err := Load("../../configs/config.docker.yaml")
if err != nil {
t.Fatalf("Load Docker config failed: %v", err)
}
if !cfg.GiftCapacityLimit.Enabled || cfg.GiftCapacityLimit.RedisAddr != "redis:6379" || cfg.GiftCapacityLimit.UserBucketCapacity != 5_000 || cfg.GiftCapacityLimit.RoomBucketCapacity != 20_000 || cfg.GiftCapacityLimit.AppBucketCapacity != 100_000 || cfg.GiftCapacityLimit.MaxInFlight != 2 {
t.Fatalf("Docker gift capacity config mismatch: %+v", cfg.GiftCapacityLimit)
}
if !containsString(cfg.CORS.AllowedHeaders, "X-App-Version") || !containsString(cfg.CORS.AllowedHeaders, "X-App-Build-Number") {
t.Fatalf("Docker CORS must allow login client version headers: %+v", cfg.CORS)
}
}
func TestLoadLegacyYAMLWithoutGiftSectionReusesAuthRedis(t *testing.T) {
if got := Default().GiftCapacityLimit.RedisAddr; got != "" {
t.Fatalf("Default gift RedisAddr must stay empty for auth Redis inheritance, got %q", got)
}
path := filepath.Join(t.TempDir(), "config.yaml")
if err := os.WriteFile(path, []byte(`auth_rate_limit:
enabled: true
redis_addr: "legacy-redis.internal:6379"
redis_password: "legacy-secret"
redis_db: 4
`), 0o600); err != nil {
t.Fatalf("write legacy config failed: %v", err)
}
cfg, err := Load(path)
if err != nil {
t.Fatalf("legacy YAML without gift_capacity_limit must still load: %v", err)
}
if !cfg.GiftCapacityLimit.Enabled || cfg.GiftCapacityLimit.RedisAddr != "legacy-redis.internal:6379" || cfg.GiftCapacityLimit.RedisPassword != "legacy-secret" || cfg.GiftCapacityLimit.RedisDB != 4 {
t.Fatalf("gift capacity must reuse auth Redis for old YAML: %+v", cfg.GiftCapacityLimit)
}
if cfg.GiftCapacityLimit.MaxTargetsPerRequest != 30 || cfg.GiftCapacityLimit.MaxGiftCountPerRequest != 999 || cfg.GiftCapacityLimit.MaxGiftUnitsPerRequest != 5_000 || cfg.GiftCapacityLimit.UserRefillTokensPerSecond != 500 || cfg.GiftCapacityLimit.RoomRefillTokensPerSecond != 5_000 || cfg.GiftCapacityLimit.AppRefillTokensPerSecond != 25_000 {
t.Fatalf("old YAML must receive safe capacity defaults: %+v", cfg.GiftCapacityLimit)
}
}
func TestNormalizeRejectsGiftLeaseWithoutRequestSafetyMargin(t *testing.T) {
cfg := Default()
cfg.GiftCapacityLimit.RequestTimeout = 8 * time.Second
cfg.GiftCapacityLimit.InFlightLease = 10 * time.Second
if err := cfg.Normalize(); err == nil || !strings.Contains(err.Error(), "in_flight_lease") {
t.Fatalf("lease equal to request timeout plus safety margin must be rejected: %v", err)
}
}
func TestNormalizeRejectsGiftBucketSmallerThanRequestMaximum(t *testing.T) {
cfg := Default()
cfg.GiftCapacityLimit.MaxGiftUnitsPerRequest = 5_000
cfg.GiftCapacityLimit.UserBucketCapacity = 4_999
if err := cfg.Normalize(); err == nil || !strings.Contains(err.Error(), "bucket capacities") {
t.Fatalf("bucket below max request weight must be rejected: %v", err)
}
}
func containsString(values []string, want string) bool {
for _, value := range values {
if value == want {

View File

@ -46,12 +46,14 @@ type quickCreateAccountData struct {
// loginPassword 处理账号密码登录;账号就是当前有效短号。
func (h *Handler) loginPassword(writer http.ResponseWriter, request *http.Request) {
var body struct {
Account string `json:"account"`
Password string `json:"password"`
DeviceID string `json:"device_id"`
Platform string `json:"platform"`
Language string `json:"language"`
Timezone string `json:"timezone"`
Account string `json:"account"`
Password string `json:"password"`
DeviceID string `json:"device_id"`
AppVersion string `json:"app_version"`
BuildNumber string `json:"build_number"`
Platform string `json:"platform"`
Language string `json:"language"`
Timezone string `json:"timezone"`
}
if !decode(writer, request, &body) {
return
@ -77,8 +79,9 @@ func (h *Handler) loginPassword(writer http.ResponseWriter, request *http.Reques
return
}
meta := authRequestMetaWithLoginContext(request, body.DeviceID, body.Platform, body.Language, body.Timezone, body.AppVersion, body.BuildNumber)
resp, err := h.userClient.LoginPassword(request.Context(), &userv1.LoginPasswordRequest{
Meta: authRequestMetaWithLoginContext(request, body.DeviceID, body.Platform, body.Language, body.Timezone),
Meta: meta,
DisplayUserId: account,
Password: body.Password,
})
@ -173,8 +176,9 @@ func (h *Handler) quickCreateAccount(writer http.ResponseWriter, request *http.R
return
}
meta := authRequestMetaWithLoginContext(request, body.DeviceID, body.Platform, body.Language, body.Timezone, body.AppVersion, body.BuildNumber)
resp, err := h.userClient.QuickCreateAccount(request.Context(), &userv1.QuickCreateAccountRequest{
Meta: authRequestMetaWithLoginContext(request, body.DeviceID, body.Platform, body.Language, body.Timezone),
Meta: meta,
Password: body.Password,
Username: body.Username,
Avatar: body.Avatar,
@ -185,8 +189,8 @@ func (h *Handler) quickCreateAccount(writer http.ResponseWriter, request *http.R
Device: body.Device,
OsVersion: body.OSVersion,
Birth: body.Birth,
AppVersion: body.AppVersion,
BuildNumber: body.BuildNumber,
AppVersion: meta.GetAppVersion(),
BuildNumber: meta.GetBuildNumber(),
Source: body.Source,
InstallChannel: body.InstallChannel,
Campaign: body.Campaign,
@ -256,8 +260,9 @@ func (h *Handler) loginThirdParty(writer http.ResponseWriter, request *http.Requ
return
}
meta := authRequestMetaWithLoginContext(request, body.DeviceID, body.Platform, body.Language, body.Timezone, body.AppVersion, body.BuildNumber)
resp, err := h.userClient.LoginThirdParty(request.Context(), &userv1.LoginThirdPartyRequest{
Meta: authRequestMetaWithLoginContext(request, body.DeviceID, body.Platform, body.Language, body.Timezone),
Meta: meta,
Provider: body.Provider,
Credential: body.Credential,
Username: body.Username,
@ -269,8 +274,8 @@ func (h *Handler) loginThirdParty(writer http.ResponseWriter, request *http.Requ
OsVersion: body.OSVersion,
Avatar: body.Avatar,
Birth: body.Birth,
AppVersion: body.AppVersion,
BuildNumber: body.BuildNumber,
AppVersion: meta.GetAppVersion(),
BuildNumber: meta.GetBuildNumber(),
Source: body.Source,
InstallChannel: body.InstallChannel,
Campaign: body.Campaign,
@ -333,8 +338,9 @@ func (h *Handler) registerThirdParty(writer http.ResponseWriter, request *http.R
return
}
meta := authRequestMetaWithLoginContext(request, body.DeviceID, body.Platform, body.Language, body.Timezone, body.AppVersion, body.BuildNumber)
resp, err := h.userClient.RegisterThirdParty(request.Context(), &userv1.RegisterThirdPartyRequest{
Meta: authRequestMetaWithLoginContext(request, body.DeviceID, body.Platform, body.Language, body.Timezone),
Meta: meta,
Provider: body.Provider,
Credential: body.Credential,
DeviceId: body.DeviceID,
@ -346,8 +352,8 @@ func (h *Handler) registerThirdParty(writer http.ResponseWriter, request *http.R
Device: body.Device,
OsVersion: body.OSVersion,
Birth: body.Birth,
AppVersion: body.AppVersion,
BuildNumber: body.BuildNumber,
AppVersion: meta.GetAppVersion(),
BuildNumber: meta.GetBuildNumber(),
Source: body.Source,
InstallChannel: body.InstallChannel,
Campaign: body.Campaign,
@ -428,6 +434,8 @@ func (h *Handler) refreshToken(writer http.ResponseWriter, request *http.Request
var body struct {
RefreshToken string `json:"refresh_token"`
DeviceID string `json:"device_id"`
AppVersion string `json:"app_version"`
BuildNumber string `json:"build_number"`
}
if !decode(writer, request, &body) {
return
@ -444,7 +452,8 @@ func (h *Handler) refreshToken(writer http.ResponseWriter, request *http.Request
}
resp, err := h.userClient.RefreshToken(request.Context(), &userv1.RefreshTokenRequest{
Meta: authRequestMeta(request, body.DeviceID),
// refresh 是登录态自动恢复的一部分,必须携带当前安装包版本;否则升级后画像会一直停留在旧显式登录版本。
Meta: authRequestMetaWithLoginContext(request, body.DeviceID, "", "", "", body.AppVersion, body.BuildNumber),
RefreshToken: body.RefreshToken,
})
if err != nil {
@ -490,10 +499,29 @@ func (h *Handler) logout(writer http.ResponseWriter, request *http.Request) {
// authRequestMeta 生成 user-service auth RPC 的统一追踪和审计元信息。
func authRequestMeta(request *http.Request, deviceID string) *userv1.RequestMeta {
return authRequestMetaWithLoginContext(request, deviceID, "", "", "")
return authRequestMetaWithLoginContext(request, deviceID, "", "", "", "", "")
}
func authRequestMetaWithLoginContext(request *http.Request, deviceID string, platform string, language string, timezone string) *userv1.RequestMeta {
func authRequestMetaWithLoginContext(
request *http.Request,
deviceID string,
platform string,
language string,
timezone string,
bodyAppVersion string,
bodyBuildNumber string,
) *userv1.RequestMeta {
// 标准 App 客户端统一从 Header 上报版本body 只为仍按旧三方登录协议接入的客户端兜底。
// 两个值必须在 gateway 只解析一次并同时写入 Meta 和三方注册字段,避免审计版本与设备资料版本互相矛盾。
appVersion := httpkit.FirstHeader(request, "X-App-Version")
if appVersion == "" {
appVersion = strings.TrimSpace(bodyAppVersion)
}
buildNumber := httpkit.FirstHeader(request, "X-App-Build-Number")
if buildNumber == "" {
buildNumber = strings.TrimSpace(bodyBuildNumber)
}
return &userv1.RequestMeta{
RequestId: httpkit.RequestIDFromContext(request.Context()),
Caller: "gateway-service",
@ -508,6 +536,8 @@ func authRequestMetaWithLoginContext(request *http.Request, deviceID string, pla
Platform: strings.ToLower(strings.TrimSpace(platform)),
Language: strings.TrimSpace(language),
Timezone: strings.TrimSpace(timezone),
AppVersion: appVersion,
BuildNumber: buildNumber,
}
}

View File

@ -101,7 +101,7 @@ func normalizeCORSConfig(config CORSConfig) CORSConfig {
config.AllowedMethods = []string{"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"}
}
if len(config.AllowedHeaders) == 0 {
config.AllowedHeaders = []string{"Authorization", "Content-Type", "X-Request-ID", "X-App-Code", "X-HY-App-Code", "X-App-Package", "X-Package-Name", "X-App-Bundle-ID", "X-Bundle-ID", "X-App-Platform", "X-Platform"}
config.AllowedHeaders = []string{"Authorization", "Content-Type", "X-Request-ID", "X-App-Code", "X-HY-App-Code", "X-App-Package", "X-Package-Name", "X-App-Bundle-ID", "X-Bundle-ID", "X-App-Platform", "X-Platform", "X-App-Version", "X-App-Build-Number"}
}
if len(config.ExposeHeaders) == 0 {
config.ExposeHeaders = []string{"X-Request-ID", "X-Hyapp-Access-Token", "X-Hyapp-Token-Type", "X-Hyapp-Expires-In"}

View File

@ -13,7 +13,7 @@ func TestCORSPreflightShortCircuitsBusinessHandler(t *testing.T) {
Enabled: true,
AllowedOrigins: []string{"https://global-interaction.com"},
AllowedMethods: []string{"POST", "OPTIONS"},
AllowedHeaders: []string{"Content-Type", "X-App-Code"},
AllowedHeaders: []string{"Content-Type", "X-App-Code", "X-App-Version", "X-App-Build-Number"},
ExposeHeaders: []string{"X-Request-ID"},
AllowCredentials: true,
MaxAgeSec: 300,
@ -24,7 +24,7 @@ func TestCORSPreflightShortCircuitsBusinessHandler(t *testing.T) {
request := httptest.NewRequest(http.MethodOptions, "/api/v1/auth/third-party/login", nil)
request.Header.Set("Origin", "https://global-interaction.com")
request.Header.Set("Access-Control-Request-Method", "POST")
request.Header.Set("Access-Control-Request-Headers", "content-type,x-app-code")
request.Header.Set("Access-Control-Request-Headers", "content-type,x-app-code,x-app-version,x-app-build-number")
recorder := httptest.NewRecorder()
handler.ServeHTTP(recorder, request)
@ -41,11 +41,21 @@ func TestCORSPreflightShortCircuitsBusinessHandler(t *testing.T) {
if got := recorder.Header().Get("Access-Control-Allow-Credentials"); got != "true" {
t.Fatalf("unexpected allow credentials: %q", got)
}
if got := recorder.Header().Get("Access-Control-Allow-Headers"); got != "Content-Type, X-App-Code" {
if got := recorder.Header().Get("Access-Control-Allow-Headers"); got != "Content-Type, X-App-Code, X-App-Version, X-App-Build-Number" {
t.Fatalf("unexpected allow headers: %q", got)
}
}
func TestCORSDefaultHeadersAllowLoginClientVersion(t *testing.T) {
config := normalizeCORSConfig(CORSConfig{})
allowedHeaders := "," + strings.ToLower(strings.Join(config.AllowedHeaders, ",")) + ","
for _, header := range []string{"X-App-Version", "X-App-Build-Number"} {
if !strings.Contains(allowedHeaders, ","+strings.ToLower(header)+",") {
t.Fatalf("default CORS headers must allow browser login version metadata %q: %+v", header, config.AllowedHeaders)
}
}
}
func TestCORSRejectsDisallowedPreflightOrigin(t *testing.T) {
called := false
handler := WithCORS(CORSConfig{

View File

@ -0,0 +1,486 @@
package http
import (
"bytes"
"context"
"encoding/json"
"errors"
"io"
"net"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/redis/go-redis/v9"
roomv1 "hyapp.local/api/proto/room/v1"
"hyapp/services/gateway-service/internal/auth"
"hyapp/services/gateway-service/internal/transport/http/giftlimit"
)
type fakeGiftCapacityLimiter struct {
decision giftlimit.Decision
err error
inputs []giftlimit.Input
releases int
}
func (f *fakeGiftCapacityLimiter) Acquire(_ context.Context, input giftlimit.Input) (giftlimit.Decision, error) {
f.inputs = append(f.inputs, input)
return f.decision, f.err
}
func (f *fakeGiftCapacityLimiter) Release(context.Context, giftlimit.Lease) error {
f.releases++
return nil
}
func TestGiftCapacityLimitWeightsUniqueTargetsAndReleasesInFlightSlot(t *testing.T) {
roomClient := &fakeRoomClient{}
limiter := &fakeGiftCapacityLimiter{decision: giftlimit.Decision{Allowed: true}}
handler := NewHandlerWithClients(roomClient, nil, nil, &fakeUserProfileClient{regionID: 1001})
handler.SetGiftCapacityProtection(true, 5*time.Second, 30, limiter)
router := handler.Routes(auth.NewVerifier("secret"))
request := httptest.NewRequest(http.MethodPost, "/api/v1/rooms/gift/batch-send", bytes.NewReader([]byte(`{"room_id":"room-1","command_id":"cmd-weighted","target_user_ids":[43,44,43,0],"gift_id":"rose","gift_count":1}`)))
request.Header.Set("Authorization", "Bearer "+signGatewayTokenWithAppCode(t, "secret", 42, "huwaa"))
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK {
t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String())
}
if len(limiter.inputs) != 1 {
t.Fatalf("limiter acquire count mismatch: %d", len(limiter.inputs))
}
input := limiter.inputs[0]
if input.AppCode != "huwaa" || input.UserID != 42 || input.RoomID != "room-1" || input.Weight != 2 || input.RequestID == "" {
t.Fatalf("weighted limiter input mismatch: %+v", input)
}
if limiter.releases != 1 {
t.Fatalf("successful acquire must release in-flight slot exactly once: %d", limiter.releases)
}
if roomClient.lastGift == nil || len(roomClient.lastGift.GetTargetUserIds()) != 2 {
t.Fatalf("normal batch-send behavior changed: %+v", roomClient.lastGift)
}
}
func TestGiftCapacityLimitPreservesSingleTargetWeight(t *testing.T) {
limiter := &fakeGiftCapacityLimiter{decision: giftlimit.Decision{Allowed: true}}
handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, &fakeUserProfileClient{regionID: 1001})
handler.SetGiftCapacityProtection(true, 5*time.Second, 30, limiter)
router := handler.Routes(auth.NewVerifier("secret"))
request := httptest.NewRequest(http.MethodPost, "/api/v1/rooms/gift/send", bytes.NewReader([]byte(`{"room_id":"room-1","command_id":"cmd-single","target_user_id":43,"gift_id":"rose","gift_count":1}`)))
request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK {
t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String())
}
if len(limiter.inputs) != 1 || limiter.inputs[0].Weight != 1 {
t.Fatalf("legacy single-target request must consume one token: %+v", limiter.inputs)
}
}
func TestGiftCapacityLimitWeightsGiftCountTimesUniqueTargets(t *testing.T) {
limiter := &fakeGiftCapacityLimiter{decision: giftlimit.Decision{Allowed: true}}
handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, &fakeUserProfileClient{regionID: 1001})
handler.SetGiftCapacityProtection(true, 5*time.Second, 30, limiter)
router := handler.Routes(auth.NewVerifier("secret"))
request := httptest.NewRequest(http.MethodPost, "/api/v1/rooms/gift/batch-send", bytes.NewReader([]byte(`{"room_id":"room-1","command_id":"cmd-units","target_user_ids":[43,44,43],"gift_id":"rose","gift_count":99}`)))
request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK || len(limiter.inputs) != 1 || limiter.inputs[0].Weight != 198 {
t.Fatalf("gift work units must be count*deduplicated targets: status=%d inputs=%+v body=%s", recorder.Code, limiter.inputs, recorder.Body.String())
}
}
func TestGiftCapacityLimitRejectsExplosiveGiftUnitsBeforeRedis(t *testing.T) {
targets := make([]int64, 0, 6)
for userID := int64(43); userID < 49; userID++ {
targets = append(targets, userID)
}
body, err := json.Marshal(map[string]any{
"room_id": "room-1",
"command_id": "cmd-too-many-units",
"target_user_ids": targets,
"gift_id": "rose",
"gift_count": 999,
})
if err != nil {
t.Fatalf("marshal body: %v", err)
}
roomClient := &fakeRoomClient{}
limiter := &fakeGiftCapacityLimiter{decision: giftlimit.Decision{Allowed: true}}
handler := NewHandlerWithClients(roomClient, nil, nil, &fakeUserProfileClient{regionID: 1001})
handler.SetGiftCapacityProtection(true, 5*time.Second, 30, limiter)
router := handler.Routes(auth.NewVerifier("secret"))
request := httptest.NewRequest(http.MethodPost, "/api/v1/rooms/gift/batch-send", bytes.NewReader(body))
request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, request)
if recorder.Code != http.StatusBadRequest || len(limiter.inputs) != 0 || roomClient.lastGift != nil {
t.Fatalf("gift_count*targets above hard cap reached Redis/downstream: status=%d inputs=%+v gift=%+v", recorder.Code, limiter.inputs, roomClient.lastGift)
}
}
func TestGiftCapacityLimitRejectsGiftCountAboveV2Contract(t *testing.T) {
roomClient := &fakeRoomClient{}
limiter := &fakeGiftCapacityLimiter{decision: giftlimit.Decision{Allowed: true}}
handler := NewHandlerWithClients(roomClient, nil, nil, &fakeUserProfileClient{regionID: 1001})
handler.SetGiftCapacityProtection(true, 5*time.Second, 30, limiter)
router := handler.Routes(auth.NewVerifier("secret"))
request := httptest.NewRequest(http.MethodPost, "/api/v1/rooms/gift/send", bytes.NewReader([]byte(`{"room_id":"room-1","command_id":"cmd-count-overflow","target_user_id":43,"gift_id":"rose","gift_count":1000}`)))
request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, request)
if recorder.Code != http.StatusBadRequest || len(limiter.inputs) != 0 || roomClient.lastGift != nil {
t.Fatalf("gift_count above 999 reached Redis/downstream: status=%d inputs=%+v gift=%+v", recorder.Code, limiter.inputs, roomClient.lastGift)
}
}
func TestSendGiftV2PreservesV1FieldsAndForwardsStableBatchIdentity(t *testing.T) {
roomClient := &fakeRoomClient{sendGiftResp: &roomv1.SendGiftResponse{
Result: &roomv1.CommandResult{Applied: true, RoomVersion: 14},
BillingReceiptId: "receipt-v2",
CoinBalanceAfter: 900,
V2: &roomv1.SendGiftResultV2{
ApiVersion: 2, CommandId: "combo-1_b1", ComboSessionId: "combo-1", BatchSeq: 1,
CommittedRoomVersion: 14, BillingReceiptId: "receipt-v2", CoinBalanceAfter: 900,
SettledGiftCount: 9, SettledTargetUserIds: []int64{43}, OperationStatus: "committed",
},
}}
handler := NewHandlerWithClients(roomClient, nil, nil, &fakeUserProfileClient{regionID: 1001})
router := handler.Routes(auth.NewVerifier("secret"))
request := httptest.NewRequest(http.MethodPost, "/api/v2/rooms/gift/send", bytes.NewReader([]byte(`{"room_id":"room-1","command_id":"combo-1_b1","combo_session_id":"combo-1","batch_seq":1,"target_user_id":43,"gift_id":"rose","gift_count":9}`)))
request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK {
t.Fatalf("V2 status mismatch: got=%d body=%s", recorder.Code, recorder.Body.String())
}
if roomClient.lastGift == nil || roomClient.lastGift.GetComboSessionId() != "combo-1" || roomClient.lastGift.GetBatchSeq() != 1 || roomClient.lastGift.GetMeta().GetCommandId() != "combo-1_b1" || roomClient.lastGift.GetDisplayMode() != "" {
t.Fatalf("V2 stable batch identity was not forwarded: %+v", roomClient.lastGift)
}
var envelope struct {
Data struct {
BillingReceiptID string `json:"billing_receipt_id"`
CoinBalanceAfter int64 `json:"coin_balance_after"`
V2 struct {
CommandID string `json:"command_id"`
CommittedRoomVersion int64 `json:"committed_room_version"`
} `json:"v2"`
} `json:"data"`
}
if err := json.Unmarshal(recorder.Body.Bytes(), &envelope); err != nil {
t.Fatalf("decode V2 response: %v", err)
}
if envelope.Data.BillingReceiptID != "receipt-v2" || envelope.Data.CoinBalanceAfter != 900 || envelope.Data.V2.CommandID != "combo-1_b1" || envelope.Data.V2.CommittedRoomVersion != 14 {
t.Fatalf("V1 compatibility or nested V2 result missing: %+v body=%s", envelope, recorder.Body.String())
}
}
func TestSendGiftV1RoutesPreserveTopLevelFieldsAndStripNestedV2(t *testing.T) {
tests := []struct {
name string
path string
body string
displayMode string
}{
{
name: "single",
path: "/api/v1/rooms/gift/send",
body: `{"room_id":"room-1","command_id":"legacy-single","target_user_id":43,"gift_id":"rose","gift_count":1}`,
},
{
name: "batch",
path: "/api/v1/rooms/gift/batch-send",
body: `{"room_id":"room-1","command_id":"legacy-batch","target_user_ids":[43,44],"gift_id":"rose","gift_count":2}`,
displayMode: "batch",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
roomClient := &fakeRoomClient{sendGiftResp: &roomv1.SendGiftResponse{
Result: &roomv1.CommandResult{Applied: true, RoomVersion: 14},
BillingReceiptId: "legacy-receipt",
RoomHeat: 81,
CoinBalanceAfter: 900,
GiftIncomeBalanceAfter: 700,
V2: &roomv1.SendGiftResultV2{
ApiVersion: 2, CommandId: "internal-result", OperationStatus: "committed",
},
}}
router := NewHandlerWithClients(roomClient, nil, nil, &fakeUserProfileClient{regionID: 1001}).Routes(auth.NewVerifier("secret"))
request := httptest.NewRequest(http.MethodPost, test.path, bytes.NewReader([]byte(test.body)))
request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK {
t.Fatalf("V1 status mismatch: got=%d body=%s", recorder.Code, recorder.Body.String())
}
if roomClient.lastGift == nil || roomClient.lastGift.GetDisplayMode() != test.displayMode {
t.Fatalf("V1 request behavior changed: %+v", roomClient.lastGift)
}
var envelope struct {
Data map[string]json.RawMessage `json:"data"`
}
if err := json.Unmarshal(recorder.Body.Bytes(), &envelope); err != nil {
t.Fatalf("decode V1 response: %v", err)
}
if _, exists := envelope.Data["v2"]; exists {
t.Fatalf("legacy V1 response exposed nested v2: %s", recorder.Body.String())
}
var receipt string
var balance int64
var roomHeat int64
if err := json.Unmarshal(envelope.Data["billing_receipt_id"], &receipt); err != nil {
t.Fatalf("decode billing_receipt_id: %v", err)
}
if err := json.Unmarshal(envelope.Data["coin_balance_after"], &balance); err != nil {
t.Fatalf("decode coin_balance_after: %v", err)
}
if err := json.Unmarshal(envelope.Data["room_heat"], &roomHeat); err != nil {
t.Fatalf("decode room_heat: %v", err)
}
if receipt != "legacy-receipt" || balance != 900 || roomHeat != 81 {
t.Fatalf("V1 top-level fields changed: receipt=%q balance=%d heat=%d body=%s", receipt, balance, roomHeat, recorder.Body.String())
}
})
}
}
func TestSendGiftV2RequiresComboIdentityBeforeDownstream(t *testing.T) {
roomClient := &fakeRoomClient{}
handler := NewHandlerWithClients(roomClient, nil, nil, &fakeUserProfileClient{regionID: 1001})
router := handler.Routes(auth.NewVerifier("secret"))
request := httptest.NewRequest(http.MethodPost, "/api/v2/rooms/gift/send", bytes.NewReader([]byte(`{"room_id":"room-1","command_id":"cmd-no-combo","target_user_id":43,"gift_id":"rose","gift_count":1}`)))
request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, request)
if recorder.Code != http.StatusBadRequest || roomClient.lastGift != nil {
t.Fatalf("V2 without combo identity reached downstream: status=%d gift=%+v body=%s", recorder.Code, roomClient.lastGift, recorder.Body.String())
}
}
func TestGiftCapacityLimitReturns429EnvelopeBeforeDownstreamCalls(t *testing.T) {
for _, endpoint := range []struct {
name string
path string
body string
}{
{name: "single", path: "/api/v1/rooms/gift/send", body: `{"room_id":"room-1","command_id":"cmd-denied-single","target_user_id":43,"gift_id":"rose","gift_count":1}`},
{name: "batch", path: "/api/v1/rooms/gift/batch-send", body: `{"room_id":"room-1","command_id":"cmd-denied-batch","target_user_ids":[43,44],"gift_id":"rose","gift_count":1}`},
} {
t.Run(endpoint.name, func(t *testing.T) {
roomClient := &fakeRoomClient{}
profileClient := &fakeUserProfileClient{regionID: 1001}
limiter := &fakeGiftCapacityLimiter{decision: giftlimit.Decision{RetryAfter: 275 * time.Millisecond}}
handler := NewHandlerWithClients(roomClient, nil, nil, profileClient)
handler.SetGiftCapacityProtection(true, 5*time.Second, 30, limiter)
router := handler.Routes(auth.NewVerifier("secret"))
request := httptest.NewRequest(http.MethodPost, endpoint.path, bytes.NewReader([]byte(endpoint.body)))
request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, request)
if recorder.Code != http.StatusTooManyRequests {
t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String())
}
if recorder.Header().Get("Retry-After") != "1" {
t.Fatalf("Retry-After mismatch: %q", recorder.Header().Get("Retry-After"))
}
var envelope struct {
Code string `json:"code"`
Message string `json:"message"`
RequestID string `json:"request_id"`
Data struct {
RetryAfterMS int64 `json:"retry_after_ms"`
} `json:"data"`
}
if err := json.Unmarshal(recorder.Body.Bytes(), &envelope); err != nil {
t.Fatalf("decode response failed: %v body=%s", err, recorder.Body.String())
}
if envelope.Code != "RATE_LIMITED" || envelope.Message != "rate limited" || envelope.RequestID == "" || envelope.Data.RetryAfterMS != 275 {
t.Fatalf("rate-limit envelope mismatch: %+v", envelope)
}
if roomClient.lastGift != nil || profileClient.lastGet != nil || len(profileClient.batchRequests) != 0 {
t.Fatalf("denied request reached downstream: gift=%+v profile_get=%+v profile_batches=%d", roomClient.lastGift, profileClient.lastGet, len(profileClient.batchRequests))
}
if limiter.releases != 0 {
t.Fatalf("denied request must not release an unowned slot: %d", limiter.releases)
}
})
}
}
func TestGiftCapacityLimitFailsClosedWhenRedisUnavailable(t *testing.T) {
roomClient := &fakeRoomClient{}
limiter := &fakeGiftCapacityLimiter{err: errors.New("redis unavailable")}
handler := NewHandlerWithClients(roomClient, nil, nil, &fakeUserProfileClient{regionID: 1001})
handler.SetGiftCapacityProtection(true, 5*time.Second, 30, limiter)
router := handler.Routes(auth.NewVerifier("secret"))
request := httptest.NewRequest(http.MethodPost, "/api/v1/rooms/gift/send", bytes.NewReader([]byte(`{"room_id":"room-1","command_id":"cmd-error","target_user_id":43,"gift_id":"rose","gift_count":1}`)))
request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, request)
if recorder.Code != http.StatusBadGateway || roomClient.lastGift != nil {
t.Fatalf("limiter backend failure must fail closed: status=%d body=%s gift=%+v", recorder.Code, recorder.Body.String(), roomClient.lastGift)
}
}
func TestGiftCapacityLimitBlockedRedisFailsClosedQuickly(t *testing.T) {
// net.Pipe 对 Redis 写入只读不回,模拟连接存在但服务端永久不返回 Lua 结果;真正的
// RedisLimiter 必须靠自己的 operation deadline 结束,而不是依赖测试 fake 立即报错。
redisClient := redis.NewClient(&redis.Options{
Addr: "blocked-redis",
MaxRetries: -1,
ReadTimeout: time.Second,
WriteTimeout: time.Second,
ContextTimeoutEnabled: true,
Dialer: func(context.Context, string, string) (net.Conn, error) {
clientConn, serverConn := net.Pipe()
go func() {
defer serverConn.Close()
_, _ = io.Copy(io.Discard, serverConn)
}()
return clientConn, nil
},
})
defer redisClient.Close()
limiter := giftlimit.NewRedisLimiter(redisClient, giftlimit.Config{RedisOperationTimeout: 50 * time.Millisecond})
roomClient := &fakeRoomClient{}
handler := NewHandlerWithClients(roomClient, nil, nil, &fakeUserProfileClient{regionID: 1001})
handler.SetGiftCapacityProtection(true, 5*time.Second, 30, limiter)
router := handler.Routes(auth.NewVerifier("secret"))
request := httptest.NewRequest(http.MethodPost, "/api/v1/rooms/gift/send", bytes.NewReader([]byte(`{"room_id":"room-1","command_id":"cmd-blocked-redis","target_user_id":43,"gift_id":"rose","gift_count":1}`)))
request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
recorder := httptest.NewRecorder()
startedAt := time.Now()
router.ServeHTTP(recorder, request)
elapsed := time.Since(startedAt)
if recorder.Code != http.StatusBadGateway || roomClient.lastGift != nil || elapsed < 35*time.Millisecond || elapsed > 500*time.Millisecond {
t.Fatalf("blocked Redis must fail closed inside short deadline: status=%d elapsed=%s body=%s gift=%+v", recorder.Code, elapsed, recorder.Body.String(), roomClient.lastGift)
}
}
func TestGiftCapacityLimitRejectsTooManyUniqueTargetsBeforeRedis(t *testing.T) {
uniqueTargets := make([]int64, 0, 31)
for userID := int64(1); userID <= 31; userID++ {
uniqueTargets = append(uniqueTargets, userID)
}
body, err := json.Marshal(map[string]any{
"room_id": "room-1",
"command_id": "cmd-too-many",
"target_user_ids": uniqueTargets,
"gift_id": "rose",
"gift_count": 1,
})
if err != nil {
t.Fatalf("marshal body: %v", err)
}
roomClient := &fakeRoomClient{}
limiter := &fakeGiftCapacityLimiter{decision: giftlimit.Decision{Allowed: true}}
handler := NewHandlerWithClients(roomClient, nil, nil, &fakeUserProfileClient{regionID: 1001})
handler.SetGiftCapacityProtection(true, 5*time.Second, 30, limiter)
router := handler.Routes(auth.NewVerifier("secret"))
request := httptest.NewRequest(http.MethodPost, "/api/v1/rooms/gift/batch-send", bytes.NewReader(body))
request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, request)
if recorder.Code != http.StatusBadRequest || len(limiter.inputs) != 0 || roomClient.lastGift != nil {
t.Fatalf("31 unique targets must fail before Redis/downstream: status=%d body=%s inputs=%+v gift=%+v", recorder.Code, recorder.Body.String(), limiter.inputs, roomClient.lastGift)
}
}
func TestGiftCapacityLimitRejectsUnsupportedTargetTypeBeforeRedis(t *testing.T) {
roomClient := &fakeRoomClient{}
limiter := &fakeGiftCapacityLimiter{decision: giftlimit.Decision{Allowed: true}}
handler := NewHandlerWithClients(roomClient, nil, nil, &fakeUserProfileClient{regionID: 1001})
handler.SetGiftCapacityProtection(true, 5*time.Second, 30, limiter)
router := handler.Routes(auth.NewVerifier("secret"))
request := httptest.NewRequest(http.MethodPost, "/api/v1/rooms/gift/send", bytes.NewReader([]byte(`{"room_id":"room-1","command_id":"cmd-unsupported","target_type":"all_mic","gift_id":"rose","gift_count":1}`)))
request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, request)
if recorder.Code != http.StatusBadRequest || len(limiter.inputs) != 0 || roomClient.lastGift != nil {
t.Fatalf("unsupported target_type must fail before Redis/downstream: status=%d body=%s inputs=%+v gift=%+v", recorder.Code, recorder.Body.String(), limiter.inputs, roomClient.lastGift)
}
}
func TestGiftCapacityLimitAppliesMaximumAfterTargetDeduplication(t *testing.T) {
targets := make([]int64, 0, 31)
for userID := int64(1); userID <= 30; userID++ {
targets = append(targets, userID)
}
targets = append(targets, 30)
body, err := json.Marshal(map[string]any{
"room_id": "room-1",
"command_id": "cmd-dedup-maximum",
"target_user_ids": targets,
"gift_id": "rose",
"gift_count": 1,
})
if err != nil {
t.Fatalf("marshal body: %v", err)
}
limiter := &fakeGiftCapacityLimiter{decision: giftlimit.Decision{RetryAfter: 10 * time.Millisecond}}
handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, &fakeUserProfileClient{regionID: 1001})
handler.SetGiftCapacityProtection(true, 5*time.Second, 30, limiter)
router := handler.Routes(auth.NewVerifier("secret"))
request := httptest.NewRequest(http.MethodPost, "/api/v1/rooms/gift/batch-send", bytes.NewReader(body))
request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, request)
if recorder.Code != http.StatusTooManyRequests || len(limiter.inputs) != 1 || limiter.inputs[0].Weight != 30 {
t.Fatalf("duplicate target must not trip max_targets_per_request: status=%d body=%s inputs=%+v", recorder.Code, recorder.Body.String(), limiter.inputs)
}
}
func TestGiftRequestTotalDeadlineReleasesInFlightLease(t *testing.T) {
roomClient := &fakeRoomClient{sendGiftWaitForContext: true}
limiter := &fakeGiftCapacityLimiter{decision: giftlimit.Decision{Allowed: true}}
handler := NewHandlerWithClients(roomClient, nil, nil, &fakeUserProfileClient{regionID: 1001})
handler.SetGiftCapacityProtection(true, 40*time.Millisecond, 30, limiter)
router := handler.Routes(auth.NewVerifier("secret"))
request := httptest.NewRequest(http.MethodPost, "/api/v1/rooms/gift/send", bytes.NewReader([]byte(`{"room_id":"room-1","command_id":"cmd-timeout","target_user_id":43,"gift_id":"rose","gift_count":1}`)))
request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
recorder := httptest.NewRecorder()
startedAt := time.Now()
router.ServeHTTP(recorder, request)
elapsed := time.Since(startedAt)
if recorder.Code == http.StatusOK || elapsed < 30*time.Millisecond || elapsed > 300*time.Millisecond {
t.Fatalf("gift orchestration must honor one total deadline: status=%d elapsed=%s body=%s", recorder.Code, elapsed, recorder.Body.String())
}
if roomClient.lastGift == nil || limiter.releases != 1 {
t.Fatalf("timed-out request must reach room once and release lease: gift=%+v releases=%d", roomClient.lastGift, limiter.releases)
}
}

View File

@ -0,0 +1,466 @@
// Package giftlimit 为 gateway 送礼入口提供跨节点容量保护。
//
// 送礼请求的最坏下游成本与 gift_count*去重 target_count 成正比:幸运礼物需要逐份开奖,
// 每个目标也会形成独立账务和事实。因此本包不按 HTTP 次数计费,而按服务端已经校验过
// 的 total_gift_units 在 App、房间、用户三层扣令牌并额外约束单用户在途请求数。
package giftlimit
import (
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"math"
"strconv"
"strings"
"time"
"github.com/redis/go-redis/v9"
)
const (
defaultKeyPrefix = "gateway:gift:capacity:"
// 一个合法请求最多 5000 gift units因此用户桶必须至少容纳一个完整请求500/s
// 让极端 999 数量请求形成自然冷却,同时普通 300ms 连击批次不会感到逐击等待。
defaultUserBucketCapacity = 5_000
defaultUserRefillTokensPerSecond = 500
// 房间和 App 层允许多个真实用户同时连击,但把 5 人叠加造成的无界增长转换成 429
// 背压。数值均为可灰度配置的保护起点,不冒充未经压测的 Broker 容量结论。
defaultRoomBucketCapacity = 20_000
defaultRoomRefillTokensPerSecond = 5_000
defaultAppBucketCapacity = 100_000
defaultAppRefillTokensPerSecond = 25_000
defaultMaxInFlight = 2
defaultInFlightLease = 10 * time.Second
defaultRedisOperationTimeout = 500 * time.Millisecond
redisReleaseTimeout = time.Second
redisDialTimeout = time.Second
redisReadTimeout = 300 * time.Millisecond
redisWriteTimeout = 300 * time.Millisecond
redisPoolTimeout = 300 * time.Millisecond
redisMinRetryBackoff = 20 * time.Millisecond
redisMaxRetryBackoff = 50 * time.Millisecond
)
// Config 描述三层令牌桶、单用户在途租约和 Redis 操作预算。
type Config struct {
Enabled bool
KeyPrefix string
UserBucketCapacity int
UserRefillTokensPerSecond float64
RoomBucketCapacity int
RoomRefillTokensPerSecond float64
AppBucketCapacity int
AppRefillTokensPerSecond float64
MaxInFlight int
InFlightLease time.Duration
RedisOperationTimeout time.Duration
}
// Input 是一次送礼请求在容量维度上的稳定身份。
type Input struct {
AppCode string
UserID int64
RoomID string
RequestID string
Weight int
}
// Lease 标识已占用的分布式在途槽。字段只供 Limiter.Release 使用。
type Lease struct {
inFlightKey string
member string
}
// Decision 返回是否获准以及客户端下一次可重试的最短等待时间。
type Decision struct {
Allowed bool
RetryAfter time.Duration
Lease Lease
}
// Limiter 是 room HTTP adapter 所需的最小容量保护边界。
type Limiter interface {
Acquire(ctx context.Context, input Input) (Decision, error)
Release(ctx context.Context, lease Lease) error
}
type redisScriptRunner interface {
evalSlice(ctx context.Context, script string, keys []string, args ...any) ([]any, error)
}
type goRedisScriptRunner struct {
client *redis.Client
}
type bucketPolicy struct {
capacity int
refillPerSecond float64
}
type redisScopeKeys struct {
appBucket string
roomBucket string
userBucket string
inFlight string
leaseMember string
}
// RedisLimiter 按 user→room→app 顺序执行三个独立 Lua。
//
// Redis Cluster 只允许一次脚本访问同一 hash slot。App、房间、用户是不同聚合域不能
// 伪装成一个跨 slot 原子事务。用户层先检查 in-flight可防止已占满并发槽的账号用必拒
// 请求持续消耗房间/App 额度;后层拒绝时释放用户 lease但保留用户令牌作为请求方成本。
// App 拒绝时不回滚房间令牌,少量保守损耗比并发回滚造成超发或绕过容量上限更安全。
type RedisLimiter struct {
runner redisScriptRunner
config Config
}
// redisTokenBucketScript 在一个聚合维度内原子补充并扣减令牌,返回
// {allowed, retry_after_ms}。Redis TIME 是多 gateway 节点的统一时间源。
const redisTokenBucketScript = `
local redis_time = redis.call("TIME")
local now_ms = tonumber(redis_time[1]) * 1000 + math.floor(tonumber(redis_time[2]) / 1000)
local capacity = tonumber(ARGV[1])
local refill_per_ms = tonumber(ARGV[2])
local weight = tonumber(ARGV[3])
local bucket_ttl_ms = tonumber(ARGV[4])
local tokens = tonumber(redis.call("HGET", KEYS[1], "tokens"))
local last_ms = tonumber(redis.call("HGET", KEYS[1], "last_ms"))
if not tokens or not last_ms then
tokens = capacity
last_ms = now_ms
else
tokens = math.min(capacity, tokens + math.max(0, now_ms - last_ms) * refill_per_ms)
end
if tokens < weight then
redis.call("HSET", KEYS[1], "tokens", tokens, "last_ms", now_ms)
redis.call("PEXPIRE", KEYS[1], bucket_ttl_ms)
local retry_ms = math.ceil((weight - tokens) / refill_per_ms)
return {0, math.max(1, retry_ms)}
end
redis.call("HSET", KEYS[1], "tokens", tokens - weight, "last_ms", now_ms)
redis.call("PEXPIRE", KEYS[1], bucket_ttl_ms)
return {1, 0}
`
// redisUserAcquireScript 把用户桶和 app:user 在途 ZSET 放进同一个 hash slot因此用户
// 额度检查、并发检查和租约写入在单个 Redis 节点内保持原子。
const redisUserAcquireScript = `
local redis_time = redis.call("TIME")
local now_ms = tonumber(redis_time[1]) * 1000 + math.floor(tonumber(redis_time[2]) / 1000)
local capacity = tonumber(ARGV[1])
local refill_per_ms = tonumber(ARGV[2])
local weight = tonumber(ARGV[3])
local max_in_flight = tonumber(ARGV[4])
local lease_ms = tonumber(ARGV[5])
local member = ARGV[6]
local bucket_ttl_ms = tonumber(ARGV[7])
-- gateway 异常退出不会执行 Release每次申请前清理过期租约确保 lease_ms 后自动恢复
redis.call("ZREMRANGEBYSCORE", KEYS[2], "-inf", now_ms)
local in_flight = redis.call("ZCARD", KEYS[2])
if in_flight >= max_in_flight then
local earliest = redis.call("ZRANGE", KEYS[2], 0, 0, "WITHSCORES")
local retry_ms = 1
if earliest[2] then
retry_ms = math.max(1, tonumber(earliest[2]) - now_ms)
end
return {0, retry_ms}
end
local tokens = tonumber(redis.call("HGET", KEYS[1], "tokens"))
local last_ms = tonumber(redis.call("HGET", KEYS[1], "last_ms"))
if not tokens or not last_ms then
tokens = capacity
last_ms = now_ms
else
tokens = math.min(capacity, tokens + math.max(0, now_ms - last_ms) * refill_per_ms)
end
if tokens < weight then
redis.call("HSET", KEYS[1], "tokens", tokens, "last_ms", now_ms)
redis.call("PEXPIRE", KEYS[1], bucket_ttl_ms)
local retry_ms = math.ceil((weight - tokens) / refill_per_ms)
return {0, math.max(1, retry_ms)}
end
redis.call("HSET", KEYS[1], "tokens", tokens - weight, "last_ms", now_ms)
redis.call("PEXPIRE", KEYS[1], bucket_ttl_ms)
redis.call("ZADD", KEYS[2], now_ms + lease_ms, member)
redis.call("PEXPIRE", KEYS[2], lease_ms * 2)
return {1, 0}
`
const redisReleaseScript = `
redis.call("ZREM", KEYS[1], ARGV[1])
if redis.call("ZCARD", KEYS[1]) == 0 then
redis.call("DEL", KEYS[1])
end
return {1}
`
// Normalize 收敛与 10k MQ TPS 和当前送礼行为匹配的安全默认值。
func Normalize(config Config) Config {
config.KeyPrefix = strings.TrimSpace(config.KeyPrefix)
if config.KeyPrefix == "" {
config.KeyPrefix = defaultKeyPrefix
}
if config.UserBucketCapacity <= 0 {
config.UserBucketCapacity = defaultUserBucketCapacity
}
if config.UserRefillTokensPerSecond <= 0 {
config.UserRefillTokensPerSecond = defaultUserRefillTokensPerSecond
}
if config.RoomBucketCapacity <= 0 {
config.RoomBucketCapacity = defaultRoomBucketCapacity
}
if config.RoomRefillTokensPerSecond <= 0 {
config.RoomRefillTokensPerSecond = defaultRoomRefillTokensPerSecond
}
if config.AppBucketCapacity <= 0 {
config.AppBucketCapacity = defaultAppBucketCapacity
}
if config.AppRefillTokensPerSecond <= 0 {
config.AppRefillTokensPerSecond = defaultAppRefillTokensPerSecond
}
if config.MaxInFlight <= 0 {
config.MaxInFlight = defaultMaxInFlight
}
if config.InFlightLease <= 0 {
config.InFlightLease = defaultInFlightLease
}
if config.RedisOperationTimeout <= 0 {
config.RedisOperationTimeout = defaultRedisOperationTimeout
}
return config
}
// NewRedisClient 建立并探测容量保护使用的 Redis 连接。
// ContextTimeoutEnabled 必须开启,否则 go-redis 可能只依赖较长 socket timeout使 handler
// 的 500ms fail-closed 预算和 Release 的 1s 回收预算失效。
func NewRedisClient(ctx context.Context, addr string, password string, db int) (*redis.Client, error) {
client := redis.NewClient(redisClientOptions(addr, password, db))
if err := client.Ping(ctx).Err(); err != nil {
_ = client.Close()
return nil, err
}
return client, nil
}
func redisClientOptions(addr string, password string, db int) *redis.Options {
return &redis.Options{
Addr: strings.TrimSpace(addr),
Password: password,
DB: db,
MaxRetries: 1,
MinRetryBackoff: redisMinRetryBackoff,
MaxRetryBackoff: redisMaxRetryBackoff,
DialTimeout: redisDialTimeout,
DialerRetries: 1,
ReadTimeout: redisReadTimeout,
WriteTimeout: redisWriteTimeout,
PoolTimeout: redisPoolTimeout,
ContextTimeoutEnabled: true,
}
}
// NewRedisLimiter 创建生产使用的分布式 limiter。
func NewRedisLimiter(client *redis.Client, config Config) *RedisLimiter {
return newRedisLimiter(goRedisScriptRunner{client: client}, config)
}
func newRedisLimiter(runner redisScriptRunner, config Config) *RedisLimiter {
return &RedisLimiter{runner: runner, config: Normalize(config)}
}
// Acquire 在一个独立短 deadline 内按 user→room→app 顺序保守扣减。
// 用户并发先行可挡住低成本全局 DoS后层失败会立即释放用户 lease但不会返还已扣令牌。
func (l *RedisLimiter) Acquire(ctx context.Context, input Input) (Decision, error) {
if l == nil || l.runner == nil {
return Decision{}, errors.New("gift capacity limiter is not configured")
}
appCode := strings.ToLower(strings.TrimSpace(input.AppCode))
if appCode == "" || input.UserID <= 0 || strings.TrimSpace(input.RoomID) == "" || strings.TrimSpace(input.RequestID) == "" || input.Weight <= 0 {
return Decision{}, errors.New("gift capacity limiter input is invalid")
}
config := Normalize(l.config)
if input.Weight > config.UserBucketCapacity || input.Weight > config.RoomBucketCapacity || input.Weight > config.AppBucketCapacity {
// handler 会先用 max_targets_per_request 返回 4xx到达这里说明容量配置与协议上限漂移
// 必须 fail-closed而不是返回一个永远无法成功的 429 重试循环。
return Decision{}, errors.New("gift target weight exceeds configured bucket capacity")
}
if ctx == nil {
ctx = context.Background()
}
operationCtx, cancel := context.WithTimeout(ctx, config.RedisOperationTimeout)
defer cancel()
keys := buildRedisScopeKeys(config.KeyPrefix, appCode, input)
userPolicy := bucketPolicy{capacity: config.UserBucketCapacity, refillPerSecond: config.UserRefillTokensPerSecond}
result, err := l.runner.evalSlice(operationCtx, redisUserAcquireScript, []string{keys.userBucket, keys.inFlight},
userPolicy.capacity,
refillPerMillisecond(userPolicy.refillPerSecond),
input.Weight,
config.MaxInFlight,
config.InFlightLease.Milliseconds(),
keys.leaseMember,
bucketTTL(userPolicy, config.InFlightLease).Milliseconds(),
)
if err != nil {
return Decision{}, err
}
userDecision, err := decodeDecision(result)
if err != nil {
// Lua 已返回但协议解码失败时执行幂等 ZREM脚本可能已经写入 lease不能等到 TTL 才恢复。
l.releaseAcquiredLease(operationCtx, Lease{inFlightKey: keys.inFlight, member: keys.leaseMember})
return Decision{}, err
}
if !userDecision.Allowed {
// 并发已满或用户桶不足时尚未触达 room/app key因此攻击者不能靠必拒请求耗尽共享额度。
return userDecision, nil
}
lease := Lease{inFlightKey: keys.inFlight, member: keys.leaseMember}
layers := []struct {
key string
policy bucketPolicy
}{
{key: keys.roomBucket, policy: bucketPolicy{capacity: config.RoomBucketCapacity, refillPerSecond: config.RoomRefillTokensPerSecond}},
{key: keys.appBucket, policy: bucketPolicy{capacity: config.AppBucketCapacity, refillPerSecond: config.AppRefillTokensPerSecond}},
}
for _, layer := range layers {
decision, err := l.consumeBucket(operationCtx, layer.key, layer.policy, input.Weight)
if err != nil || !decision.Allowed {
// 使用同一个短 deadline 立即释放用户 leasedeadline 已耗尽时释放可能失败,
// 但 ZSET score 的 InFlightLease 仍会兜底回收,绝不会永久锁死用户。
l.releaseAcquiredLease(operationCtx, lease)
return decision, err
}
}
userDecision.Lease = lease
return userDecision, nil
}
func (l *RedisLimiter) consumeBucket(ctx context.Context, key string, policy bucketPolicy, weight int) (Decision, error) {
result, err := l.runner.evalSlice(ctx, redisTokenBucketScript, []string{key},
policy.capacity,
refillPerMillisecond(policy.refillPerSecond),
weight,
bucketTTL(policy, 0).Milliseconds(),
)
if err != nil {
return Decision{}, err
}
return decodeDecision(result)
}
func (l *RedisLimiter) releaseAcquiredLease(ctx context.Context, lease Lease) {
if lease.inFlightKey == "" || lease.member == "" {
return
}
// 不调用公开 Release避免给一次 Acquire 额外再增加 1s补偿动作必须服从 Acquire 的
// 共同短预算。失败时租约 TTL 是安全兜底,用户 token 则有意不返还。
_, _ = l.runner.evalSlice(ctx, redisReleaseScript, []string{lease.inFlightKey}, lease.member)
}
// Release 始终再包一层 1s deadline。handler 使用与客户端取消解耦的 background context
// go-redis 又启用 ContextTimeoutEnabled因此慢 Redis 不会把请求收尾永久挂住。
func (l *RedisLimiter) Release(ctx context.Context, lease Lease) error {
if l == nil || l.runner == nil || lease.inFlightKey == "" || lease.member == "" {
return nil
}
if ctx == nil {
ctx = context.Background()
}
releaseCtx, cancel := context.WithTimeout(ctx, redisReleaseTimeout)
defer cancel()
_, err := l.runner.evalSlice(releaseCtx, redisReleaseScript, []string{lease.inFlightKey}, lease.member)
return err
}
func (r goRedisScriptRunner) evalSlice(ctx context.Context, script string, keys []string, args ...any) ([]any, error) {
return r.client.Eval(ctx, script, keys, args...).Slice()
}
func buildRedisScopeKeys(prefix string, appCode string, input Input) redisScopeKeys {
appPart := boundedHash(appCode)
roomPart := boundedHash(appCode + ":" + strings.TrimSpace(input.RoomID))
userPart := boundedHash(appCode + ":" + strconv.FormatInt(input.UserID, 10))
member := boundedHash(input.RequestID)
// 每个聚合域拥有自己的 hash tag只有用户桶与 in-flight ZSET 共享 user tag因而只有
// 这两个 key 会放入同一 Lua。App/room/user 三层绝不跨 slot 伪原子。
return redisScopeKeys{
appBucket: prefix + "{app:" + appPart + "}:bucket",
roomBucket: prefix + "{room:" + roomPart + "}:bucket",
userBucket: prefix + "{user:" + userPart + "}:bucket",
inFlight: prefix + "{user:" + userPart + "}:inflight",
leaseMember: member,
}
}
func bucketTTL(policy bucketPolicy, minimum time.Duration) time.Duration {
ttl := time.Duration(math.Ceil(float64(policy.capacity)/policy.refillPerSecond*2)) * time.Second
if ttl < time.Second {
ttl = time.Second
}
if ttl < minimum {
ttl = minimum
}
return ttl
}
func refillPerMillisecond(tokensPerSecond float64) string {
return strconv.FormatFloat(tokensPerSecond/1000, 'f', -1, 64)
}
func decodeDecision(result []any) (Decision, error) {
if len(result) != 2 {
return Decision{}, fmt.Errorf("gift capacity limiter returned %d values", len(result))
}
allowed, err := redisInt64(result[0])
if err != nil {
return Decision{}, fmt.Errorf("decode gift capacity allowed: %w", err)
}
retryAfterMS, err := redisInt64(result[1])
if err != nil {
return Decision{}, fmt.Errorf("decode gift capacity retry_after_ms: %w", err)
}
if allowed == 1 {
return Decision{Allowed: true}, nil
}
if retryAfterMS < 1 {
retryAfterMS = 1
}
return Decision{RetryAfter: time.Duration(retryAfterMS) * time.Millisecond}, nil
}
func boundedHash(value string) string {
sum := sha256.Sum256([]byte(strings.TrimSpace(value)))
return hex.EncodeToString(sum[:16])
}
func redisInt64(value any) (int64, error) {
switch typed := value.(type) {
case int64:
return typed, nil
case int:
return int64(typed), nil
case string:
return strconv.ParseInt(typed, 10, 64)
case []byte:
return strconv.ParseInt(string(typed), 10, 64)
default:
return 0, fmt.Errorf("unsupported redis integer type %T", value)
}
}

View File

@ -0,0 +1,135 @@
package giftlimit
import (
"context"
"fmt"
"os"
"testing"
"time"
)
// TestRedisLimiterIntegration 在真实 Redis 上验证三层聚合、加权扣减和租约时序。
// 常规 CI 没有 Redis 时跳过;本地用 GIFT_LIMIT_REDIS_ADDR=127.0.0.1:13379 显式执行。
func TestRedisLimiterIntegration(t *testing.T) {
addr := os.Getenv("GIFT_LIMIT_REDIS_ADDR")
if addr == "" {
t.Skip("GIFT_LIMIT_REDIS_ADDR is not configured")
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
client, err := NewRedisClient(ctx, addr, os.Getenv("GIFT_LIMIT_REDIS_PASSWORD"), 0)
if err != nil {
t.Fatalf("connect Redis: %v", err)
}
defer client.Close()
prefix := fmt.Sprintf("test:gift:capacity:%d:", time.Now().UnixNano())
t.Cleanup(func() {
cleanupCtx, cleanupCancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cleanupCancel()
var cursor uint64
for {
keys, next, scanErr := client.Scan(cleanupCtx, cursor, prefix+"*", 100).Result()
if scanErr != nil {
return
}
if len(keys) > 0 {
_ = client.Del(cleanupCtx, keys...).Err()
}
cursor = next
if cursor == 0 {
return
}
}
})
// 小容量专门让测试在无等待情况下依次触发 App、房间和用户层每个场景使用不同
// app_code避免前一场景的保守扣减影响后一场景。
limiter := NewRedisLimiter(client, Config{
Enabled: true,
KeyPrefix: prefix,
UserBucketCapacity: 2,
UserRefillTokensPerSecond: 0.1,
RoomBucketCapacity: 2,
RoomRefillTokensPerSecond: 0.1,
AppBucketCapacity: 3,
AppRefillTokensPerSecond: 0.1,
MaxInFlight: 1,
InFlightLease: 300 * time.Millisecond,
RedisOperationTimeout: 500 * time.Millisecond,
})
t.Run("app aggregate", func(t *testing.T) {
first := mustAcquire(t, limiter, Input{AppCode: "app-total", UserID: 1, RoomID: "room-a", RequestID: "app-1", Weight: 2})
mustRelease(t, limiter, first.Lease)
second, err := limiter.Acquire(context.Background(), Input{AppCode: "app-total", UserID: 2, RoomID: "room-b", RequestID: "app-2", Weight: 2})
if err != nil || second.Allowed || second.RetryAfter <= 0 {
t.Fatalf("app aggregate must reject cross-room users: decision=%+v err=%v", second, err)
}
})
t.Run("room aggregate across users", func(t *testing.T) {
first := mustAcquire(t, limiter, Input{AppCode: "room-total", UserID: 11, RoomID: "same-room", RequestID: "room-1", Weight: 1})
mustRelease(t, limiter, first.Lease)
second := mustAcquire(t, limiter, Input{AppCode: "room-total", UserID: 12, RoomID: "same-room", RequestID: "room-2", Weight: 1})
mustRelease(t, limiter, second.Lease)
third, err := limiter.Acquire(context.Background(), Input{AppCode: "room-total", UserID: 13, RoomID: "same-room", RequestID: "room-3", Weight: 1})
if err != nil || third.Allowed || third.RetryAfter <= 0 {
t.Fatalf("room bucket must aggregate different users: decision=%+v err=%v", third, err)
}
// room 层拒绝后用户 lease 必须立即释放;用户 token 有意保留扣减,因此剩余的一个
// token 仍可在另一个未满房间使用。
afterRoomDenial := mustAcquire(t, limiter, Input{AppCode: "room-total", UserID: 13, RoomID: "other-room", RequestID: "room-4", Weight: 1})
mustRelease(t, limiter, afterRoomDenial.Lease)
})
t.Run("user weighted", func(t *testing.T) {
first := mustAcquire(t, limiter, Input{AppCode: "user-total", UserID: 21, RoomID: "room-a", RequestID: "user-1", Weight: 1})
mustRelease(t, limiter, first.Lease)
weighted, err := limiter.Acquire(context.Background(), Input{AppCode: "user-total", UserID: 21, RoomID: "room-b", RequestID: "user-2", Weight: 2})
if err != nil || weighted.Allowed || weighted.RetryAfter < time.Second {
t.Fatalf("user bucket must retain weighted consumption across rooms: decision=%+v err=%v", weighted, err)
}
})
t.Run("in-flight lease does not expire early", func(t *testing.T) {
leaseLimiter := NewRedisLimiter(client, Config{
KeyPrefix: prefix,
UserBucketCapacity: 100,
UserRefillTokensPerSecond: 100,
RoomBucketCapacity: 100,
RoomRefillTokensPerSecond: 100,
AppBucketCapacity: 100,
AppRefillTokensPerSecond: 100,
MaxInFlight: 1,
InFlightLease: 300 * time.Millisecond,
RedisOperationTimeout: 500 * time.Millisecond,
})
first := mustAcquire(t, leaseLimiter, Input{AppCode: "lease", UserID: 31, RoomID: "room-a", RequestID: "lease-1", Weight: 1})
_ = first // 故意不释放,模拟 gateway 在 RPC 期间仍持有槽位。
time.Sleep(100 * time.Millisecond)
beforeExpiry, err := leaseLimiter.Acquire(context.Background(), Input{AppCode: "lease", UserID: 31, RoomID: "room-b", RequestID: "lease-2", Weight: 1})
if err != nil || beforeExpiry.Allowed || beforeExpiry.RetryAfter < 100*time.Millisecond {
t.Fatalf("lease expired before configured boundary: decision=%+v err=%v", beforeExpiry, err)
}
time.Sleep(230 * time.Millisecond)
afterExpiry := mustAcquire(t, leaseLimiter, Input{AppCode: "lease", UserID: 31, RoomID: "room-c", RequestID: "lease-3", Weight: 1})
mustRelease(t, leaseLimiter, afterExpiry.Lease)
})
}
func mustAcquire(t *testing.T, limiter *RedisLimiter, input Input) Decision {
t.Helper()
decision, err := limiter.Acquire(context.Background(), input)
if err != nil || !decision.Allowed {
t.Fatalf("Acquire mismatch: input=%+v decision=%+v err=%v", input, decision, err)
}
return decision
}
func mustRelease(t *testing.T, limiter *RedisLimiter, lease Lease) {
t.Helper()
if err := limiter.Release(context.Background(), lease); err != nil {
t.Fatalf("Release failed: %v", err)
}
}

View File

@ -0,0 +1,254 @@
package giftlimit
import (
"context"
"errors"
"reflect"
"strings"
"testing"
"time"
)
type redisScriptCall struct {
script string
keys []string
args []any
}
type fakeRedisScriptRunner struct {
result []any
results [][]any
errors []error
calls []redisScriptCall
}
func (f *fakeRedisScriptRunner) evalSlice(_ context.Context, script string, keys []string, args ...any) ([]any, error) {
f.calls = append(f.calls, redisScriptCall{
script: script,
keys: append([]string(nil), keys...),
args: append([]any(nil), args...),
})
if len(f.errors) > 0 {
err := f.errors[0]
f.errors = f.errors[1:]
if err != nil {
return nil, err
}
}
if len(f.results) > 0 {
result := f.results[0]
f.results = f.results[1:]
return result, nil
}
return f.result, nil
}
type blockingRedisScriptRunner struct{}
func (blockingRedisScriptRunner) evalSlice(ctx context.Context, _ string, _ []string, _ ...any) ([]any, error) {
<-ctx.Done()
return nil, ctx.Err()
}
func TestRedisClientOptionsEnforceBoundedContextAwareIO(t *testing.T) {
options := redisClientOptions(" 127.0.0.1:6379 ", "secret", 3)
if options.Addr != "127.0.0.1:6379" || options.Password != "secret" || options.DB != 3 {
t.Fatalf("Redis identity options mismatch: %+v", options)
}
if !options.ContextTimeoutEnabled || options.MaxRetries != 1 || options.DialerRetries != 1 {
t.Fatalf("Redis timeout/retry guard mismatch: %+v", options)
}
if options.DialTimeout != redisDialTimeout || options.ReadTimeout != redisReadTimeout || options.WriteTimeout != redisWriteTimeout || options.PoolTimeout != redisPoolTimeout {
t.Fatalf("Redis socket/pool timeouts mismatch: %+v", options)
}
}
func TestRedisLimiterAcquireUsesThreeWeightedClusterSafeLayers(t *testing.T) {
runner := &fakeRedisScriptRunner{result: []any{int64(1), int64(0)}}
limiter := newRedisLimiter(runner, Config{
KeyPrefix: "test:gift:",
UserBucketCapacity: 40,
UserRefillTokensPerSecond: 15,
RoomBucketCapacity: 120,
RoomRefillTokensPerSecond: 40,
AppBucketCapacity: 1000,
AppRefillTokensPerSecond: 500,
MaxInFlight: 2,
InFlightLease: 10 * time.Second,
RedisOperationTimeout: time.Second,
})
decision, err := limiter.Acquire(context.Background(), Input{
AppCode: "LALU",
UserID: 42,
RoomID: "room:unsafe/value",
RequestID: "req-1",
Weight: 13,
})
if err != nil {
t.Fatalf("Acquire failed: %v", err)
}
if !decision.Allowed || decision.Lease.inFlightKey == "" || decision.Lease.member == "" {
t.Fatalf("allowed decision must include release lease: %+v", decision)
}
if len(runner.calls) != 3 {
t.Fatalf("Acquire must execute app, room and user layers: calls=%d", len(runner.calls))
}
if runner.calls[0].script != redisUserAcquireScript || runner.calls[1].script != redisTokenBucketScript || runner.calls[2].script != redisTokenBucketScript {
t.Fatalf("layer script order mismatch: %+v", runner.calls)
}
if len(runner.calls[0].keys) != 2 || len(runner.calls[1].keys) != 1 || len(runner.calls[2].keys) != 1 {
t.Fatalf("each Lua must stay inside one aggregation slot: %+v", runner.calls)
}
appTag := redisHashTag(runner.calls[2].keys[0])
roomTag := redisHashTag(runner.calls[1].keys[0])
userTag := redisHashTag(runner.calls[0].keys[0])
if appTag == "" || roomTag == "" || userTag == "" || appTag == roomTag || appTag == userTag || roomTag == userTag {
t.Fatalf("aggregation layers must use distinct hash slots: app=%q room=%q user=%q", appTag, roomTag, userTag)
}
if redisHashTag(runner.calls[0].keys[1]) != userTag {
t.Fatalf("user bucket and in-flight lease must share one hash slot: %v", runner.calls[0].keys)
}
for _, call := range runner.calls {
for _, key := range call.keys {
if strings.Contains(key, "room:unsafe") || strings.Contains(key, "LALU") {
t.Fatalf("untrusted scope leaked into Redis key: %q", key)
}
}
if got := call.args[2]; got != 13 {
t.Fatalf("target_count must weight every layer: got=%v call=%+v", got, call)
}
}
if runner.calls[0].args[3] != 2 || runner.calls[0].args[4] != int64(10_000) {
t.Fatalf("user concurrency/lease args mismatch: %v", runner.calls[0].args)
}
}
func TestRedisLimiterStopsAtRejectedLayerWithoutUnsafeRollback(t *testing.T) {
tests := []struct {
name string
results [][]any
wantCalls int
wantRetryMS int64
}{
{name: "user", results: [][]any{{int64(0), int64(101)}}, wantCalls: 1, wantRetryMS: 101},
{name: "room", results: [][]any{{int64(1), int64(0)}, {int64(0), int64(202)}, {int64(1)}}, wantCalls: 3, wantRetryMS: 202},
{name: "app", results: [][]any{{int64(1), int64(0)}, {int64(1), int64(0)}, {int64(0), int64(303)}, {int64(1)}}, wantCalls: 4, wantRetryMS: 303},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
runner := &fakeRedisScriptRunner{results: test.results}
limiter := newRedisLimiter(runner, Config{})
decision, err := limiter.Acquire(context.Background(), Input{AppCode: "lalu", UserID: 42, RoomID: "room-1", RequestID: "req-deny", Weight: 1})
if err != nil {
t.Fatalf("Acquire failed: %v", err)
}
if decision.Allowed || decision.RetryAfter != time.Duration(test.wantRetryMS)*time.Millisecond || len(runner.calls) != test.wantCalls {
t.Fatalf("denied layer mismatch: decision=%+v calls=%d", decision, len(runner.calls))
}
if test.name != "user" && runner.calls[len(runner.calls)-1].script != redisReleaseScript {
t.Fatalf("shared-layer rejection must immediately release user lease: calls=%+v", runner.calls)
}
})
}
}
func TestRedisLimiterInFlightDenialDoesNotConsumeRoomOrApp(t *testing.T) {
runner := &fakeRedisScriptRunner{result: []any{int64(0), int64(250)}}
limiter := newRedisLimiter(runner, Config{})
decision, err := limiter.Acquire(context.Background(), Input{AppCode: "lalu", UserID: 42, RoomID: "room-1", RequestID: "req-concurrent", Weight: 13})
if err != nil || decision.Allowed || decision.RetryAfter != 250*time.Millisecond {
t.Fatalf("user in-flight denial mismatch: decision=%+v err=%v", decision, err)
}
if len(runner.calls) != 1 || runner.calls[0].script != redisUserAcquireScript || len(runner.calls[0].keys) != 2 {
t.Fatalf("in-flight denial must stop before room/app: calls=%+v", runner.calls)
}
}
func TestRedisScopeKeysAggregateDifferentUsersInSameRoom(t *testing.T) {
left := buildRedisScopeKeys("test:", "lalu", Input{UserID: 42, RoomID: "room-1", RequestID: "req-left"})
right := buildRedisScopeKeys("test:", "lalu", Input{UserID: 43, RoomID: "room-1", RequestID: "req-right"})
if left.appBucket != right.appBucket || left.roomBucket != right.roomBucket {
t.Fatalf("different users in one room must share app and room buckets: left=%+v right=%+v", left, right)
}
if left.userBucket == right.userBucket || left.inFlight == right.inFlight {
t.Fatalf("different users must keep independent user buckets and leases: left=%+v right=%+v", left, right)
}
}
func TestRedisLimiterReleaseUsesLeaseIdentity(t *testing.T) {
runner := &fakeRedisScriptRunner{result: []any{int64(1), int64(0)}}
limiter := newRedisLimiter(runner, Config{})
decision, err := limiter.Acquire(context.Background(), Input{AppCode: "lalu", UserID: 42, RoomID: "room-1", RequestID: "req-release", Weight: 1})
if err != nil {
t.Fatalf("Acquire failed: %v", err)
}
wantKey := decision.Lease.inFlightKey
wantMember := decision.Lease.member
if err := limiter.Release(context.Background(), decision.Lease); err != nil {
t.Fatalf("Release failed: %v", err)
}
lastCall := runner.calls[len(runner.calls)-1]
if !reflect.DeepEqual(lastCall.keys, []string{wantKey}) || !reflect.DeepEqual(lastCall.args, []any{wantMember}) || lastCall.script != redisReleaseScript {
t.Fatalf("release identity mismatch: %+v", lastCall)
}
}
func TestRedisLimiterAcquireDeadlineFailsClosedQuickly(t *testing.T) {
limiter := newRedisLimiter(blockingRedisScriptRunner{}, Config{RedisOperationTimeout: 30 * time.Millisecond})
startedAt := time.Now()
_, err := limiter.Acquire(context.Background(), Input{AppCode: "lalu", UserID: 42, RoomID: "room-1", RequestID: "req-slow", Weight: 1})
elapsed := time.Since(startedAt)
if !errors.Is(err, context.DeadlineExceeded) {
t.Fatalf("blocked Redis must fail closed with deadline: %v", err)
}
if elapsed < 20*time.Millisecond || elapsed > 200*time.Millisecond {
t.Fatalf("blocked Redis escaped short operation budget: elapsed=%s", elapsed)
}
}
func TestRedisLimiterReleaseDeadlineIsBounded(t *testing.T) {
limiter := newRedisLimiter(blockingRedisScriptRunner{}, Config{})
startedAt := time.Now()
err := limiter.Release(context.Background(), Lease{inFlightKey: "test:{user}:inflight", member: "req"})
elapsed := time.Since(startedAt)
if !errors.Is(err, context.DeadlineExceeded) {
t.Fatalf("blocked release must honor deadline: %v", err)
}
if elapsed < 900*time.Millisecond || elapsed > 1500*time.Millisecond {
t.Fatalf("release deadline is not the configured 1s bound: elapsed=%s", elapsed)
}
}
func TestRedisLimiterPropagatesLayerAndProtocolErrors(t *testing.T) {
wantErr := errors.New("redis unavailable")
runner := &fakeRedisScriptRunner{result: []any{int64(1), int64(0)}, errors: []error{nil, wantErr, nil}}
limiter := newRedisLimiter(runner, Config{})
input := Input{AppCode: "lalu", UserID: 42, RoomID: "room-1", RequestID: "req-error", Weight: 1}
if _, err := limiter.Acquire(context.Background(), input); !errors.Is(err, wantErr) || len(runner.calls) != 3 || runner.calls[2].script != redisReleaseScript {
t.Fatalf("room-layer backend error mismatch: err=%v calls=%d", err, len(runner.calls))
}
limiter = newRedisLimiter(&fakeRedisScriptRunner{result: []any{int64(1)}}, Config{})
if _, err := limiter.Acquire(context.Background(), input); err == nil {
t.Fatal("invalid Lua response must fail closed")
}
}
func TestRedisLimiterRejectsWeightAboveConfiguredCapacity(t *testing.T) {
runner := &fakeRedisScriptRunner{result: []any{int64(1), int64(0)}}
limiter := newRedisLimiter(runner, Config{UserBucketCapacity: 2})
_, err := limiter.Acquire(context.Background(), Input{AppCode: "lalu", UserID: 42, RoomID: "room-1", RequestID: "req-heavy", Weight: 3})
if err == nil || len(runner.calls) != 0 {
t.Fatalf("impossible weight must fail before Redis: err=%v calls=%d", err, len(runner.calls))
}
}
func redisHashTag(key string) string {
start := strings.IndexByte(key, '{')
end := strings.IndexByte(key, '}')
if start < 0 || end <= start {
return ""
}
return key[start+1 : end]
}

View File

@ -15,6 +15,7 @@ import (
"hyapp/services/gateway-service/internal/client"
"hyapp/services/gateway-service/internal/financewithdrawal"
"hyapp/services/gateway-service/internal/transport/http/appapi"
"hyapp/services/gateway-service/internal/transport/http/giftlimit"
"hyapp/services/gateway-service/internal/transport/http/httpkit"
userv1 "hyapp.local/api/proto/user/v1"
@ -69,6 +70,12 @@ type Handler struct {
objectUploader ObjectUploader
authRateLimitConfig AuthRateLimitConfig
authRateLimiter publicAuthRateLimiter
giftCapacityEnabled bool
giftCapacityLimiter giftlimit.Limiter
giftRequestTimeout time.Duration
giftMaxTargets int
giftMaxCount int
giftMaxUnits int
loginRiskConfig LoginRiskConfig
loginRiskCache loginRiskCache
}
@ -174,6 +181,33 @@ func (h *Handler) SetObjectUploader(uploader ObjectUploader) {
h.objectUploader = uploader
}
// SetGiftCapacityProtection 注入跨 gateway 节点共享的送礼容量保护和 HTTP 编排边界。
// enabled=true 且 limiter=nil 会在请求路径 fail-closed避免配置漂移时静默失去保护。
func (h *Handler) SetGiftCapacityProtection(enabled bool, requestTimeout time.Duration, maxTargets int, limiter giftlimit.Limiter) {
h.giftCapacityEnabled = enabled
h.giftCapacityLimiter = limiter
h.giftRequestTimeout = requestTimeout
h.giftMaxTargets = maxTargets
if h.giftMaxCount <= 0 {
h.giftMaxCount = 999
}
if h.giftMaxUnits <= 0 {
h.giftMaxUnits = 5_000
}
}
// SetGiftRequestLimits keeps protocol hard limits separate from Redis availability. Even when the
// distributed limiter is intentionally disabled, malformed or explosive gift_count*target_count
// requests still fail before profile, wallet and Room Cell work begins.
func (h *Handler) SetGiftRequestLimits(maxCount int, maxUnits int) {
if maxCount > 0 {
h.giftMaxCount = maxCount
}
if maxUnits > 0 {
h.giftMaxUnits = maxUnits
}
}
// SetRoomQueryClient 注入 room-service 读模型查询 client。
func (h *Handler) SetRoomQueryClient(roomQueryClient client.RoomQueryClient) {
h.roomQueryClient = roomQueryClient

View File

@ -6,7 +6,10 @@ import (
"hyapp/services/gateway-service/internal/transport/http/httpkit"
)
const APIV1Prefix = "/api/v1"
const (
APIV1Prefix = "/api/v1"
APIV2Prefix = "/api/v2"
)
type Wrapper func(http.HandlerFunc) http.Handler
@ -191,6 +194,7 @@ type RoomHandlers struct {
UnbanUser http.HandlerFunc
SendGift http.HandlerFunc
SendGiftBatch http.HandlerFunc
SendGiftV2 http.HandlerFunc
}
type MessageHandlers struct {
@ -386,6 +390,10 @@ func (r routes) profile(path string, method string, next http.HandlerFunc) {
r.register(path, method, r.config.ProfileWrap, next)
}
func (r routes) profileV2(path string, method string, next http.HandlerFunc) {
r.registerRaw(APIV2Prefix+path, method, r.config.ProfileWrap, next)
}
func (r routes) register(path string, method string, wrap Wrapper, next http.HandlerFunc) {
r.registerRaw(APIV1Prefix+path, method, wrap, next)
}
@ -565,6 +573,9 @@ func (r routes) registerRoomRoutes() {
r.profile("/rooms/user/unban", http.MethodPost, h.UnbanUser)
r.profile("/rooms/gift/send", http.MethodPost, h.SendGift)
r.profile("/rooms/gift/batch-send", http.MethodPost, h.SendGiftBatch)
// V2 adds micro-batch identity and committed-result replay while the two V1 routes remain byte-level
// compatible for already released Flutter versions.
r.profileV2("/rooms/gift/send", http.MethodPost, h.SendGiftV2)
}
func (r routes) registerMessageRoutes() {

View File

@ -230,7 +230,7 @@ func (h *Handler) loginRiskIPWhitelisted(request *http.Request, input loginRiskI
ctx, cancel := context.WithTimeout(request.Context(), 500*time.Millisecond)
defer cancel()
resp, err := h.userClient.CheckLoginRiskIPWhitelist(ctx, &userv1.CheckLoginRiskIPWhitelistRequest{
Meta: authRequestMetaWithLoginContext(request, "", input.Platform, input.Language, input.Timezone),
Meta: authRequestMetaWithLoginContext(request, "", input.Platform, input.Language, input.Timezone, "", ""),
ClientIp: ip,
})
if err != nil {
@ -248,7 +248,7 @@ func (h *Handler) recordLoginBlocked(request *http.Request, input loginRiskInput
ctx, cancel := context.WithTimeout(request.Context(), 500*time.Millisecond)
defer cancel()
_, err := h.userClient.RecordLoginBlocked(ctx, &userv1.RecordLoginBlockedRequest{
Meta: authRequestMetaWithLoginContext(request, "", input.Platform, input.Language, input.Timezone),
Meta: authRequestMetaWithLoginContext(request, "", input.Platform, input.Language, input.Timezone, "", ""),
Channel: input.Channel,
Platform: input.Platform,
Language: input.Language,

View File

@ -40,35 +40,36 @@ const testAppAccessTokenTTLSec int64 = 604800
// fakeRoomClient 只承接 gateway transport 测试需要观察的 gRPC 入参。
// 测试目标是 HTTP envelope 和 RequestMeta 传递,不验证 room-service 业务行为。
type fakeRoomClient struct {
lastCreate *roomv1.CreateRoomRequest
lastUpdateProfile *roomv1.UpdateRoomProfileRequest
lastSaveBackground *roomv1.SaveRoomBackgroundRequest
lastSetBackground *roomv1.SetRoomBackgroundRequest
lastJoin *roomv1.JoinRoomRequest
lastHeartbeat *roomv1.RoomHeartbeatRequest
lastLeave *roomv1.LeaveRoomRequest
lastMicUp *roomv1.MicUpRequest
lastMicDown *roomv1.MicDownRequest
lastChangeMicSeat *roomv1.ChangeMicSeatRequest
lastConfirmMic *roomv1.ConfirmMicPublishingRequest
lastMicHeartbeat *roomv1.MicHeartbeatRequest
lastSetMicMute *roomv1.SetMicMuteRequest
lastRTCEvent *roomv1.ApplyRTCEventRequest
lastMicSeatLock *roomv1.SetMicSeatLockRequest
lastChatEnabled *roomv1.SetChatEnabledRequest
lastRoomPassword *roomv1.SetRoomPasswordRequest
lastSetAdmin *roomv1.SetRoomAdminRequest
lastMute *roomv1.MuteUserRequest
lastKick *roomv1.KickUserRequest
lastUnban *roomv1.UnbanUserRequest
lastGift *roomv1.SendGiftRequest
sendGiftResp *roomv1.SendGiftResponse
lastFollowRoom *roomv1.FollowRoomRequest
lastUnfollowRoom *roomv1.UnfollowRoomRequest
createErr error
updateProfileErr error
joinErr error
joinResp *roomv1.JoinRoomResponse
lastCreate *roomv1.CreateRoomRequest
lastUpdateProfile *roomv1.UpdateRoomProfileRequest
lastSaveBackground *roomv1.SaveRoomBackgroundRequest
lastSetBackground *roomv1.SetRoomBackgroundRequest
lastJoin *roomv1.JoinRoomRequest
lastHeartbeat *roomv1.RoomHeartbeatRequest
lastLeave *roomv1.LeaveRoomRequest
lastMicUp *roomv1.MicUpRequest
lastMicDown *roomv1.MicDownRequest
lastChangeMicSeat *roomv1.ChangeMicSeatRequest
lastConfirmMic *roomv1.ConfirmMicPublishingRequest
lastMicHeartbeat *roomv1.MicHeartbeatRequest
lastSetMicMute *roomv1.SetMicMuteRequest
lastRTCEvent *roomv1.ApplyRTCEventRequest
lastMicSeatLock *roomv1.SetMicSeatLockRequest
lastChatEnabled *roomv1.SetChatEnabledRequest
lastRoomPassword *roomv1.SetRoomPasswordRequest
lastSetAdmin *roomv1.SetRoomAdminRequest
lastMute *roomv1.MuteUserRequest
lastKick *roomv1.KickUserRequest
lastUnban *roomv1.UnbanUserRequest
lastGift *roomv1.SendGiftRequest
sendGiftResp *roomv1.SendGiftResponse
sendGiftWaitForContext bool
lastFollowRoom *roomv1.FollowRoomRequest
lastUnfollowRoom *roomv1.UnfollowRoomRequest
createErr error
updateProfileErr error
joinErr error
joinResp *roomv1.JoinRoomResponse
}
func (f *fakeRoomClient) CreateRoom(_ context.Context, req *roomv1.CreateRoomRequest) (*roomv1.CreateRoomResponse, error) {
@ -274,8 +275,12 @@ func (f *fakeRoomClient) UnbanUser(_ context.Context, req *roomv1.UnbanUserReque
return &roomv1.UnbanUserResponse{Result: &roomv1.CommandResult{Applied: true, RoomVersion: 13}}, nil
}
func (f *fakeRoomClient) SendGift(_ context.Context, req *roomv1.SendGiftRequest) (*roomv1.SendGiftResponse, error) {
func (f *fakeRoomClient) SendGift(ctx context.Context, req *roomv1.SendGiftRequest) (*roomv1.SendGiftResponse, error) {
f.lastGift = req
if f.sendGiftWaitForContext {
<-ctx.Done()
return nil, status.FromContextError(ctx.Err()).Err()
}
if f.sendGiftResp != nil {
return f.sendGiftResp, nil
}
@ -7267,6 +7272,9 @@ func TestThirdPartyLoginUsesPublicEnvelopeAndPropagatesRequestID(t *testing.T) {
if req.GetMeta().GetRequestId() != generatedRequestID || req.GetMeta().GetClientIp() != "203.0.113.10" || req.GetMeta().GetUserAgent() != "hyapp-test/1.0" || req.GetMeta().GetCountryByIp() != "SG" {
t.Fatalf("auth meta was not propagated from gateway context: %+v", req.GetMeta())
}
if req.GetMeta().GetAppVersion() != "1.2.3" || req.GetMeta().GetBuildNumber() != "123" {
t.Fatalf("third-party body version fallback was not propagated into auth meta: %+v", req.GetMeta())
}
if req.GetUsername() != "hy" || req.GetInviteCode() != "INV-1" || req.GetDeviceId() != "dev-1" {
t.Fatalf("third-party registration fields were not propagated: %+v", req)
}
@ -7290,6 +7298,8 @@ func TestThirdPartyRegisterUsesPublicEnvelopeAndReturnsCompletedToken(t *testing
request.Header.Set("X-Forwarded-For", "203.0.113.10")
request.Header.Set("User-Agent", "hyapp-test/1.0")
request.Header.Set("CF-IPCountry", "sg")
request.Header.Set("X-App-Version", "2.0.0")
request.Header.Set("X-App-Build-Number", "200")
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, request)
@ -7319,6 +7329,9 @@ func TestThirdPartyRegisterUsesPublicEnvelopeAndReturnsCompletedToken(t *testing
if req.GetMeta().GetRequestId() != generatedRequestID || req.GetMeta().GetClientIp() != "203.0.113.10" || req.GetMeta().GetCountryByIp() != "SG" {
t.Fatalf("auth meta was not propagated from gateway context: %+v", req.GetMeta())
}
if req.GetMeta().GetAppVersion() != "2.0.0" || req.GetMeta().GetBuildNumber() != "200" || req.GetAppVersion() != "2.0.0" || req.GetBuildNumber() != "200" {
t.Fatalf("register version headers must override body values in both auth meta and registration fields: %+v meta=%+v", req, req.GetMeta())
}
if req.GetProvider() != "firebase" || req.GetCredential() != "firebase-id-token-1" || req.GetDeviceId() != "dev-1" {
t.Fatalf("provider credential device fields mismatch: %+v", req)
}
@ -7437,9 +7450,11 @@ func TestAccountLoginRateLimitByAccount(t *testing.T) {
func TestAccountLoginAcceptsAccountField(t *testing.T) {
userClient := &fakeUserAuthClient{}
router := NewHandler(&fakeRoomClient{}, userClient).Routes(auth.NewVerifier("secret"))
body := []byte(`{"account":"123456","password":"1234567","device_id":"ios","platform":"ios","language":"en-US","timezone":"America/Los_Angeles"}`)
body := []byte(`{"account":"123456","password":"1234567","device_id":"ios","app_version":"1.2.3","build_number":"123","platform":"ios","language":"en-US","timezone":"America/Los_Angeles"}`)
request := httptest.NewRequest(http.MethodPost, "/api/v1/auth/account/login", bytes.NewReader(body))
request.Header.Set("X-Request-ID", "req-account-login")
request.Header.Set("X-App-Version", "2.0.0")
request.Header.Set("X-App-Build-Number", "200")
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, request)
@ -7467,6 +7482,9 @@ func TestAccountLoginAcceptsAccountField(t *testing.T) {
if userClient.lastLoginPassword.GetMeta().GetPlatform() != "ios" || userClient.lastLoginPassword.GetMeta().GetLanguage() != "en-US" || userClient.lastLoginPassword.GetMeta().GetTimezone() != "America/Los_Angeles" {
t.Fatalf("password login risk meta mismatch: %+v", userClient.lastLoginPassword.GetMeta())
}
if userClient.lastLoginPassword.GetMeta().GetAppVersion() != "2.0.0" || userClient.lastLoginPassword.GetMeta().GetBuildNumber() != "200" {
t.Fatalf("password login version headers must override body fallback: %+v", userClient.lastLoginPassword.GetMeta())
}
}
func TestQuickCreateAccountCreatesCompletedPasswordAccount(t *testing.T) {
@ -7476,6 +7494,8 @@ func TestQuickCreateAccountCreatesCompletedPasswordAccount(t *testing.T) {
request := httptest.NewRequest(http.MethodPost, "/api/v1/auth/account/quick-create", bytes.NewReader(body))
request.Header.Set("X-Request-ID", "req-quick-create")
request.Header.Set("X-App-Code", "lalu")
request.Header.Set("X-App-Version", "3.0.0")
request.Header.Set("X-App-Build-Number", "300")
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, request)
@ -7506,6 +7526,9 @@ func TestQuickCreateAccountCreatesCompletedPasswordAccount(t *testing.T) {
if req.GetMeta().GetAppCode() != "lalu" || req.GetMeta().GetDeviceId() != req.GetDeviceId() {
t.Fatalf("quick create meta mismatch: %+v", req.GetMeta())
}
if req.GetMeta().GetAppVersion() != "3.0.0" || req.GetMeta().GetBuildNumber() != "300" || req.GetAppVersion() != "3.0.0" || req.GetBuildNumber() != "300" {
t.Fatalf("quick create version headers were not propagated consistently: %+v meta=%+v", req, req.GetMeta())
}
data, ok := response.Data.(map[string]any)
if !ok || data["uid"] != "100001" || data["account"] != "100001" || data["access_token"] != "access-quick" || data["password_set"] != true {
@ -7666,6 +7689,27 @@ func TestRefreshRateLimitByDeviceID(t *testing.T) {
}
}
func TestRefreshPropagatesCurrentClientVersionWithHeaderPriority(t *testing.T) {
userClient := &fakeUserAuthClient{}
router := NewHandler(&fakeRoomClient{}, userClient).Routes(auth.NewVerifier("secret"))
body := []byte(`{"refresh_token":"refresh-1","device_id":"dev-1","app_version":"1.2.3","build_number":"123"}`)
request := httptest.NewRequest(http.MethodPost, "/api/v1/auth/token/refresh", bytes.NewReader(body))
request.Header.Set("X-Request-ID", "req-refresh-version")
request.Header.Set("X-App-Version", "2.0.0")
request.Header.Set("X-App-Build-Number", "200")
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK {
t.Fatalf("status mismatch: got=%d body=%s", recorder.Code, recorder.Body.String())
}
meta := userClient.lastRefresh.GetMeta()
if meta.GetAppVersion() != "2.0.0" || meta.GetBuildNumber() != "200" {
t.Fatalf("refresh client version mismatch: %+v", meta)
}
}
func TestLogoutRateLimitBySessionIDAndIP(t *testing.T) {
userClient := &fakeUserAuthClient{}
handler := NewHandler(&fakeRoomClient{}, userClient)

View File

@ -0,0 +1,75 @@
package roomapi
import (
"context"
"net/http"
"strconv"
"time"
"hyapp/pkg/appcode"
"hyapp/services/gateway-service/internal/auth"
"hyapp/services/gateway-service/internal/transport/http/giftlimit"
"hyapp/services/gateway-service/internal/transport/http/httpkit"
)
const giftCapacityReleaseTimeout = time.Second
type giftRateLimitedData struct {
RetryAfterMS int64 `json:"retry_after_ms"`
}
// acquireGiftCapacity 在任何用户资料或 room-service RPC 前占用容量。
// 返回的 lease 必须 defer 释放;令牌代表本次请求已经给下游造成的成本,不因后续业务失败退还。
func (h *Handler) acquireGiftCapacity(writer http.ResponseWriter, request *http.Request, roomID string, totalGiftUnits int) (giftlimit.Lease, bool) {
if !h.giftCapacityEnabled {
return giftlimit.Lease{}, true
}
if h.giftCapacityLimiter == nil {
// 生产配置要求保护默认开启;后端缺失时 fail-closed不能在 Redis/装配故障时静默放开洪峰。
httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
return giftlimit.Lease{}, false
}
if totalGiftUnits < 1 {
totalGiftUnits = 1
}
decision, err := h.giftCapacityLimiter.Acquire(request.Context(), giftlimit.Input{
AppCode: appcode.FromContext(request.Context()),
UserID: auth.UserIDFromContext(request.Context()),
RoomID: roomID,
RequestID: httpkit.ResponseRequestID(request),
Weight: totalGiftUnits,
})
if err != nil {
httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
return giftlimit.Lease{}, false
}
if decision.Allowed {
return decision.Lease, true
}
retryAfter := decision.RetryAfter
if retryAfter < time.Millisecond {
retryAfter = time.Millisecond
}
retryAfterMS := retryAfter.Milliseconds()
// Retry-After 是标准 HTTP 秒级提示data.retry_after_ms 保留连击送礼所需的毫秒精度。
writer.Header().Set("Retry-After", strconv.FormatInt((retryAfterMS+999)/1000, 10))
httpkit.WriteEnvelope(writer, http.StatusTooManyRequests, httpkit.ResponseEnvelope{
Code: httpkit.CodeRateLimited,
Message: "rate limited",
RequestID: httpkit.ResponseRequestID(request),
Data: giftRateLimitedData{RetryAfterMS: retryAfterMS},
})
return giftlimit.Lease{}, false
}
// releaseGiftCapacity 使用与客户端断连解耦的短 contextRedis 暂时失败时租约 TTL 仍会兜底回收。
func (h *Handler) releaseGiftCapacity(lease giftlimit.Lease) {
if h.giftCapacityLimiter == nil {
return
}
ctx, cancel := context.WithTimeout(context.Background(), giftCapacityReleaseTimeout)
defer cancel()
_ = h.giftCapacityLimiter.Release(ctx, lease)
}

View File

@ -0,0 +1,52 @@
package roomapi
import (
"context"
"crypto/sha256"
"encoding/binary"
"fmt"
"strings"
"hyapp/services/gateway-service/internal/appconfig"
)
type giftComboConfigReader interface {
GiftComboConfig(ctx context.Context, appCode string) (appconfig.GiftComboConfig, error)
}
// roomGiftComboConfig resolves rollout on the server so every device logged in as the same App user
// receives the same policy. The client does not need to reproduce a hashing algorithm or trust a
// locally generated random bucket across reinstallations.
func (h *Handler) roomGiftComboConfig(ctx context.Context, appCode string, userID int64) (appconfig.GiftComboConfig, error) {
config := appconfig.DefaultGiftComboConfig()
reader := h.giftComboReader
if reader != nil {
var err error
config, err = reader.GiftComboConfig(ctx, appCode)
if err != nil {
return disabledGiftComboFallback(), err
}
}
assigned := giftComboRolloutBucket(appCode, userID) < uint32(config.RolloutBasisPoints)
// 灰度和 enabled 只控制客户端是否把连续点击合成微批;当前 Flutter 的账务入口始终是
// /api/v2/rooms/gift/send。旧客户端是否使用 V1 由其已发布代码决定,不能通过面板配置
// 把新客户端切回旧财务协议。
config.Enabled = config.Enabled && assigned
config.APIVersion = 2
return config, nil
}
func disabledGiftComboFallback() appconfig.GiftComboConfig {
config := appconfig.DefaultGiftComboConfig()
config.Enabled = false
// 配置读取失败只关闭微批,避免未知策略放大请求;接口版本仍固定为 V2。
config.APIVersion = 2
return config
}
func giftComboRolloutBucket(appCode string, userID int64) uint32 {
identity := fmt.Sprintf("%s:%d", strings.ToLower(strings.TrimSpace(appCode)), userID)
digest := sha256.Sum256([]byte(identity))
return binary.BigEndian.Uint32(digest[:4]) % 10_000
}

View File

@ -0,0 +1,44 @@
package roomapi
import (
"context"
"errors"
"testing"
"hyapp/services/gateway-service/internal/appconfig"
)
type fakeGiftComboConfigReader struct {
config appconfig.GiftComboConfig
err error
}
func (f fakeGiftComboConfigReader) GiftComboConfig(context.Context, string) (appconfig.GiftComboConfig, error) {
return f.config, f.err
}
func TestRoomGiftComboConfigAssignsStableRolloutAndDisablesBatchingForHoldback(t *testing.T) {
config := appconfig.DefaultGiftComboConfig()
bucket := giftComboRolloutBucket("lalu", 42)
config.RolloutBasisPoints = int32(bucket)
handler := &Handler{giftComboReader: fakeGiftComboConfigReader{config: config}}
resolved, err := handler.roomGiftComboConfig(context.Background(), "lalu", 42)
if err != nil {
t.Fatalf("roomGiftComboConfig failed: %v", err)
}
if resolved.Enabled || resolved.APIVersion != 2 {
t.Fatalf("holdback must disable only micro-batching while preserving the V2 contract: %+v", resolved)
}
if got := giftComboRolloutBucket(" LALU ", 42); got != bucket {
t.Fatalf("rollout bucket must be normalized and stable: got=%d want=%d", got, bucket)
}
}
func TestRoomGiftComboConfigReadFailureDisablesBatching(t *testing.T) {
handler := &Handler{giftComboReader: fakeGiftComboConfigReader{err: errors.New("admin db unavailable")}}
resolved, err := handler.roomGiftComboConfig(context.Background(), "lalu", 42)
if err == nil || resolved.Enabled || resolved.APIVersion != 2 || resolved.MaxPendingBatches <= 0 {
t.Fatalf("remote read outage must disable batching without downgrading the V2 contract: config=%+v err=%v", resolved, err)
}
}

View File

@ -15,6 +15,7 @@ import (
"hyapp/pkg/appcode"
"hyapp/pkg/logx"
"hyapp/pkg/roomid"
"hyapp/services/gateway-service/internal/appconfig"
"hyapp/services/gateway-service/internal/auth"
"hyapp/services/gateway-service/internal/transport/http/httpkit"
@ -141,6 +142,13 @@ func (h *Handler) serveRoomGiftPanel(writer http.ResponseWriter, request *http.R
resp, err := h.roomGiftBagResources(request, viewerUserID)
return resp, false, err
})
comboConfigCh := startRoomGiftPanelStage(&stageGroup, totalCtx, requestID, roomID, "combo_config", func() (appconfig.GiftComboConfig, bool, error) {
config, err := h.roomGiftComboConfig(totalCtx, app, viewerUserID)
// Remote policy failure disables micro-batching without blocking the gift panel. The stage still
// returns the error so observability distinguishes an explicit kill switch from a database outage;
// 当前 Flutter 无论是否聚合都继续走 V2不能因配置故障切回旧财务入口。
return config, err != nil, err
})
snapshotResp, _, err := waitRoomGiftPanelStage(totalCtx, snapshotCh)
if err != nil {
@ -185,6 +193,14 @@ func (h *Handler) serveRoomGiftPanel(writer http.ResponseWriter, request *http.R
h.writeRoomGiftPanelFailure(writer, request, requestID, roomID, totalStartedAt, err)
return
}
comboConfig, _, comboErr := waitRoomGiftPanelStage(totalCtx, comboConfigCh)
if comboErr != nil {
if totalCtx.Err() != nil {
h.writeRoomGiftPanelFailure(writer, request, requestID, roomID, totalStartedAt, comboErr)
return
}
comboConfig = disabledGiftComboFallback()
}
panelGifts := append([]giftConfigData{}, panelConfig.Gifts...)
panelGifts = append(panelGifts, roomGiftBagGiftsFromResources(bagResources, panelConfig.Gifts)...)
@ -194,6 +210,7 @@ func (h *Handler) serveRoomGiftPanel(writer http.ResponseWriter, request *http.R
Tabs: roomGiftTabs(panelGifts, panelConfig.GiftTypes),
Gifts: panelGifts,
QuantityPresets: []int32{1, 9, 99, 999},
ComboConfig: comboConfig,
}
logRoomGiftPanelStage(totalCtx, requestID, roomID, "total", totalStartedAt, nil, false)
httpkit.WriteOK(writer, request, data)

View File

@ -7,26 +7,34 @@ import (
"hyapp/pkg/tencentrtc"
"hyapp/services/gateway-service/internal/client"
"hyapp/services/gateway-service/internal/transport/http/appapi"
"hyapp/services/gateway-service/internal/transport/http/giftlimit"
"hyapp/services/gateway-service/internal/transport/http/httproutes"
)
// Handler owns room-facing HTTP endpoints in gateway.
// It keeps gateway as protocol adapter only: room state still belongs to room-service.
type Handler struct {
roomClient client.RoomClient
roomGuardClient client.RoomGuardClient
roomQueryClient client.RoomQueryClient
userProfileClient client.UserProfileClient
userCountryClient client.UserCountryQueryClient
userRegionClient client.UserRegionClient
userSocialClient client.UserSocialClient
userHostClient client.UserHostClient
appConfigReader appapi.ConfigReader
walletClient client.WalletClient
giftPanelCache *roomGiftPanelConfigCache
growthLevelClient client.GrowthLevelClient
achievementClient client.AchievementClient
rtcTokenConfig tencentrtc.TokenConfig
roomClient client.RoomClient
roomGuardClient client.RoomGuardClient
roomQueryClient client.RoomQueryClient
userProfileClient client.UserProfileClient
userCountryClient client.UserCountryQueryClient
userRegionClient client.UserRegionClient
userSocialClient client.UserSocialClient
userHostClient client.UserHostClient
appConfigReader appapi.ConfigReader
giftComboReader giftComboConfigReader
walletClient client.WalletClient
giftPanelCache *roomGiftPanelConfigCache
growthLevelClient client.GrowthLevelClient
achievementClient client.AchievementClient
giftCapacityEnabled bool
giftCapacityLimiter giftlimit.Limiter
giftRequestTimeout time.Duration
giftMaxTargets int
giftMaxCount int
giftMaxUnits int
rtcTokenConfig tencentrtc.TokenConfig
// gift-panel 的预算保留在 handler 内,生产使用固定安全值;测试可缩短预算验证真实超时分支,
// 避免把 Flutter 的 15 秒客户端超时当成后端取消机制。
@ -35,22 +43,45 @@ type Handler struct {
}
type Config struct {
RoomClient client.RoomClient
RoomGuardClient client.RoomGuardClient
RoomQueryClient client.RoomQueryClient
UserProfileClient client.UserProfileClient
UserCountryClient client.UserCountryQueryClient
UserRegionClient client.UserRegionClient
UserSocialClient client.UserSocialClient
UserHostClient client.UserHostClient
AppConfigReader appapi.ConfigReader
WalletClient client.WalletClient
GrowthLevelClient client.GrowthLevelClient
AchievementClient client.AchievementClient
RTCTokenConfig tencentrtc.TokenConfig
RoomClient client.RoomClient
RoomGuardClient client.RoomGuardClient
RoomQueryClient client.RoomQueryClient
UserProfileClient client.UserProfileClient
UserCountryClient client.UserCountryQueryClient
UserRegionClient client.UserRegionClient
UserSocialClient client.UserSocialClient
UserHostClient client.UserHostClient
AppConfigReader appapi.ConfigReader
WalletClient client.WalletClient
GrowthLevelClient client.GrowthLevelClient
AchievementClient client.AchievementClient
GiftCapacityEnabled bool
GiftCapacityLimiter giftlimit.Limiter
GiftRequestTimeout time.Duration
GiftMaxTargets int
GiftMaxCount int
GiftMaxUnits int
RTCTokenConfig tencentrtc.TokenConfig
}
func New(config Config) *Handler {
giftRequestTimeout := config.GiftRequestTimeout
if giftRequestTimeout <= 0 {
giftRequestTimeout = 5 * time.Second
}
giftMaxTargets := config.GiftMaxTargets
if giftMaxTargets <= 0 {
giftMaxTargets = 30
}
giftMaxCount := config.GiftMaxCount
if giftMaxCount <= 0 {
giftMaxCount = 999
}
giftMaxUnits := config.GiftMaxUnits
if giftMaxUnits <= 0 {
giftMaxUnits = 5_000
}
comboReader, _ := any(config.AppConfigReader).(giftComboConfigReader)
return &Handler{
roomClient: config.RoomClient,
roomGuardClient: config.RoomGuardClient,
@ -61,10 +92,17 @@ func New(config Config) *Handler {
userSocialClient: config.UserSocialClient,
userHostClient: config.UserHostClient,
appConfigReader: config.AppConfigReader,
giftComboReader: comboReader,
walletClient: config.WalletClient,
giftPanelCache: newRoomGiftPanelConfigCache(45 * time.Second),
growthLevelClient: config.GrowthLevelClient,
achievementClient: config.AchievementClient,
giftCapacityEnabled: config.GiftCapacityEnabled,
giftCapacityLimiter: config.GiftCapacityLimiter,
giftRequestTimeout: giftRequestTimeout,
giftMaxTargets: giftMaxTargets,
giftMaxCount: giftMaxCount,
giftMaxUnits: giftMaxUnits,
rtcTokenConfig: config.RTCTokenConfig,
giftPanelTotalBudget: roomGiftPanelTotalBudget,
giftPanelRecipientCoreBudget: roomGiftPanelRecipientCoreBudget,
@ -112,6 +150,7 @@ func (h *Handler) RoomHandlers() httproutes.RoomHandlers {
UnbanUser: h.unbanUser,
SendGift: h.sendGift,
SendGiftBatch: h.sendGiftBatch,
SendGiftV2: h.sendGiftV2,
}
}

View File

@ -13,6 +13,7 @@ import (
"time"
"hyapp/pkg/appcode"
"hyapp/pkg/giftlimits"
"hyapp/pkg/roomid"
"hyapp/pkg/xerr"
"hyapp/services/gateway-service/internal/auth"
@ -1887,38 +1888,107 @@ func (h *Handler) sendGift(writer http.ResponseWriter, request *http.Request) {
h.handleSendGift(writer, request, "")
}
// sendGiftBatch 是新 Flutter 的多人送礼入口。
// 它不改变账务和房间事实,只通过 display_mode=batch 要求 room-service 合并房间展示 IM。
// sendGiftBatch 仅保留给已发布旧客户端的 V1 多人送礼入口。
// 它不改变账务和房间事实,只通过 display_mode=batch 要求 room-service 合并房间展示 IM
// 当前 Flutter 无论单人或多人都固定调用 sendGiftV2。
func (h *Handler) sendGiftBatch(writer http.ResponseWriter, request *http.Request) {
h.handleSendGift(writer, request, "batch")
}
// sendGiftV2 unifies single/multi-target gift batches. It preserves every V1 response field and adds
// a nested V2 settlement result, so old model parsing remains valid while new Flutter can reject stale
// responses and replay the exact committed result after a timeout.
func (h *Handler) sendGiftV2(writer http.ResponseWriter, request *http.Request) {
h.handleSendGift(writer, request, "auto_v2")
}
func (h *Handler) handleSendGift(writer http.ResponseWriter, request *http.Request, displayMode string) {
protocolV2 := displayMode == "auto_v2"
var body struct {
RoomID string `json:"room_id"`
CommandID string `json:"command_id"`
TargetType string `json:"target_type"`
TargetUserID int64 `json:"target_user_id"`
TargetUserIDs []int64 `json:"target_user_ids"`
GiftID string `json:"gift_id"`
GiftCount int32 `json:"gift_count"`
PoolID string `json:"pool_id"`
EntitlementID string `json:"entitlement_id"`
Source string `json:"source"`
RoomID string `json:"room_id"`
CommandID string `json:"command_id"`
TargetType string `json:"target_type"`
TargetUserID int64 `json:"target_user_id"`
TargetUserIDs []int64 `json:"target_user_ids"`
GiftID string `json:"gift_id"`
GiftCount int32 `json:"gift_count"`
PoolID string `json:"pool_id"`
EntitlementID string `json:"entitlement_id"`
Source string `json:"source"`
ComboSessionID string `json:"combo_session_id"`
BatchSeq int64 `json:"batch_seq"`
}
if !httpkit.Decode(writer, request, &body) {
return
}
roomID := strings.TrimSpace(body.RoomID)
if roomID == "" {
httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument")
return
}
targetType := strings.TrimSpace(body.TargetType)
if targetType == "" {
targetType = "user"
}
targetUserIDs := normalizeGiftTargetUserIDs(body.TargetUserID, body.TargetUserIDs)
if targetType == "user" && len(targetUserIDs) == 0 {
if targetType != "user" {
// room-service 当前协议只开放 user未知类型必须在进入 limiter 前明确 4xx
// 不能用零目标请求污染容量 key或因 limiter 输入不完整退化成 502。
httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument")
return
}
targetUserIDs := normalizeGiftTargetUserIDs(body.TargetUserID, body.TargetUserIDs)
if len(targetUserIDs) == 0 {
httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument")
return
}
if len(targetUserIDs) > h.giftMaxTargets {
// 权重超过所有桶容量时永远不可能靠等待恢复;在进入 Redis 前明确拒绝为 4xx
// 同时用去重后的目标数判定,保留旧客户端重复提交同一 target 的兼容性。
httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument")
return
}
if displayMode == "auto_v2" {
// RoomGiftBatchSent is only the merged multi-recipient display event. A single-recipient V2
// micro-batch still gets V2 settlement metadata but must not enter the legacy multi-target path.
if len(targetUserIDs) > 1 {
displayMode = "batch"
} else {
displayMode = ""
}
}
commandID := strings.TrimSpace(body.CommandID)
comboSessionID := strings.TrimSpace(body.ComboSessionID)
if !giftlimits.ValidCommandID(commandID) || strings.TrimSpace(body.GiftID) == "" || body.GiftCount <= 0 || int(body.GiftCount) > h.giftMaxCount {
// command_id is the financial idempotency key, not a free-form trace field. Bound it before
// Redis/MySQL keys are built; gift_count also gets a transport cap before any profile RPC.
httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument")
return
}
if protocolV2 && (comboSessionID == "" || len(comboSessionID) > giftlimits.MaxCommandIDBytes || body.BatchSeq <= 0) {
// V2 batches expose the session/sequence used to derive command_id. Both fields are persisted
// in the idempotency payload, so a retry cannot silently attach the same key to another batch.
httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument")
return
}
totalGiftUnits, hardLimitOK := giftlimits.WorkUnits(int64(body.GiftCount), len(targetUserIDs))
if !hardLimitOK || totalGiftUnits > int64(h.giftMaxUnits) {
// A per-count limit alone still permits 999*30 lucky draws. The product quantity remains
// available for a small recipient set while explosive fan-out is rejected deterministically.
httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument")
return
}
lease, allowed := h.acquireGiftCapacity(writer, request, roomID, int(totalGiftUnits))
if !allowed {
return
}
defer h.releaseGiftCapacity(lease)
// profile、host 和最终 room SendGift 共用一个总 deadline不能让三个串行 RPC 各自
// 获得完整 gRPC 默认超时。in_flight lease 在配置校验中严格大于此预算并额外留 2s。
giftCtx, cancelGift := context.WithTimeout(request.Context(), h.giftRequestTimeout)
defer cancelGift()
request = request.WithContext(giftCtx)
userSnapshots, err := h.resolveGiftUserSnapshots(request, targetUserIDs)
if err != nil {
httpkit.WriteRPCError(writer, request, err)
@ -1932,7 +2002,7 @@ func (h *Handler) handleSendGift(writer http.ResponseWriter, request *http.Reque
firstTargetScope := firstGiftTargetHostScope(targetHostScopes, firstUserID(targetUserIDs))
resp, err := h.roomClient.SendGift(request.Context(), &roomv1.SendGiftRequest{
Meta: httpkit.RoomMeta(request, body.RoomID, body.CommandID),
Meta: httpkit.RoomMeta(request, roomID, commandID),
TargetType: targetType,
TargetUserIds: targetUserIDs,
TargetUserId: firstUserID(targetUserIDs),
@ -1950,10 +2020,37 @@ func (h *Handler) handleSendGift(writer http.ResponseWriter, request *http.Reque
SenderDisplayProfile: userSnapshots.senderDisplayProfile,
TargetDisplayProfiles: userSnapshots.targetDisplayProfiles,
DisplayMode: strings.TrimSpace(displayMode),
ComboSessionId: comboSessionID,
BatchSeq: body.BatchSeq,
})
if err == nil && !protocolV2 {
// room-service 对所有送礼命令都持久化 typed V2 结果,保证内部 Saga 和幂等重放只有
// 一套实现;但旧 HTTP 路由的外部 JSON 必须保持原形。这里重新组装所有 V1 顶层字段,
// 只剥离内部追加的 v2既不修改 gRPC 返回对象,也不影响 V2 路由返回完整结算结果。
resp = legacySendGiftHTTPResponse(resp)
}
httpkit.Write(writer, request, resp, err)
}
func legacySendGiftHTTPResponse(resp *roomv1.SendGiftResponse) *roomv1.SendGiftResponse {
if resp == nil {
return nil
}
return &roomv1.SendGiftResponse{
Result: resp.GetResult(),
BillingReceiptId: resp.GetBillingReceiptId(),
RoomHeat: resp.GetRoomHeat(),
GiftRank: resp.GetGiftRank(),
Room: resp.GetRoom(),
Rocket: resp.GetRocket(),
LuckyGift: resp.GetLuckyGift(),
LuckyGifts: resp.GetLuckyGifts(),
CoinBalanceAfter: resp.GetCoinBalanceAfter(),
GiftIncomeBalanceAfter: resp.GetGiftIncomeBalanceAfter(),
BatchDisplay: resp.GetBatchDisplay(),
}
}
type roomActorJoinSnapshot struct {
countryID int64
regionID int64

View File

@ -5,6 +5,7 @@ import (
"hyapp/pkg/imgroup"
"hyapp/pkg/tencentrtc"
"hyapp/services/gateway-service/internal/appconfig"
roomv1 "hyapp.local/api/proto/room/v1"
walletv1 "hyapp.local/api/proto/wallet/v1"
@ -231,6 +232,9 @@ type roomGiftPanelData struct {
Tabs []roomGiftTabData `json:"tabs"`
Gifts []giftConfigData `json:"gifts"`
QuantityPresets []int32 `json:"quantity_presets"`
// ComboConfig is additive: old Flutter models ignore it, while V2 clients receive batching,
// concurrency, retry and rollout policy in the same response that opens the gift panel.
ComboConfig appconfig.GiftComboConfig `json:"combo_config"`
}
type roomRocketData struct {

View File

@ -23,18 +23,24 @@ import (
// 路径注册集中在 httproutesgateway 依赖装配和鉴权 wrapper 仍留在 transport 入口层。
func (h *Handler) Routes(jwtVerifier *auth.Verifier) http.Handler {
roomAPI := roomapi.New(roomapi.Config{
RoomClient: h.roomClient,
RoomGuardClient: h.roomGuardClient,
RoomQueryClient: h.roomQueryClient,
UserProfileClient: h.userProfileClient,
UserCountryClient: h.userCountryClient,
UserRegionClient: h.userRegionClient,
UserSocialClient: h.userSocialClient,
UserHostClient: h.userHostClient,
AppConfigReader: h.appConfigReader,
WalletClient: h.walletClient,
GrowthLevelClient: h.growthLevelClient,
AchievementClient: h.achievementClient,
RoomClient: h.roomClient,
RoomGuardClient: h.roomGuardClient,
RoomQueryClient: h.roomQueryClient,
UserProfileClient: h.userProfileClient,
UserCountryClient: h.userCountryClient,
UserRegionClient: h.userRegionClient,
UserSocialClient: h.userSocialClient,
UserHostClient: h.userHostClient,
AppConfigReader: h.appConfigReader,
WalletClient: h.walletClient,
GrowthLevelClient: h.growthLevelClient,
AchievementClient: h.achievementClient,
GiftCapacityEnabled: h.giftCapacityEnabled,
GiftCapacityLimiter: h.giftCapacityLimiter,
GiftRequestTimeout: h.giftRequestTimeout,
GiftMaxTargets: h.giftMaxTargets,
GiftMaxCount: h.giftMaxCount,
GiftMaxUnits: h.giftMaxUnits,
RTCTokenConfig: tencentrtc.TokenConfig{
Enabled: h.tencentRTC.Enabled,
SDKAppID: h.tencentRTC.SDKAppID,

View File

@ -155,6 +155,16 @@ type DrawResult struct {
CreatedAtMS int64
WalletTransactionID string
CoinBalanceAfter int64
// Hits 只保留 gift_count 批量中的中奖子抽,避免为了连击表现把所有 999 条审计记录塞进同步响应。
Hits []DrawHit
}
type DrawHit struct {
GiftIndex int32
DrawID string
SelectedTierID string
MultiplierPPM int64
RewardCoins int64
}
type DrawSummary struct {

View File

@ -0,0 +1,71 @@
package luckygift
import (
"context"
"fmt"
"strings"
"testing"
"hyapp/pkg/xerr"
domain "hyapp/services/lucky-gift-service/internal/domain/luckygift"
)
type batchLimitRepository struct {
Repository
batchCalls int
}
func (r *batchLimitRepository) ExecuteLuckyGiftDrawBatch(_ context.Context, cmds []domain.DrawCommand, _ int64) ([]domain.DrawResult, error) {
r.batchCalls++
return make([]domain.DrawResult, len(cmds)), nil
}
func TestNormalizeDrawCommandRejectsOversizedInternalGiftWork(t *testing.T) {
base := domain.DrawCommand{
CommandID: "cmd-lucky",
UserID: 1,
DeviceID: "device-1",
RoomID: "room-1",
AnchorID: "2",
GiftID: "rose",
GiftCount: 1,
CoinSpent: 10,
TargetUserID: 2,
}
svc := &Service{}
oversizedCount := base
oversizedCount.GiftCount = 1_000
if _, err := svc.normalizeDrawCommand(oversizedCount); err == nil {
t.Fatal("gift_count above 999 was accepted")
}
oversizedID := base
oversizedID.CommandID = strings.Repeat("x", 129)
if _, err := svc.normalizeDrawCommand(oversizedID); err == nil {
t.Fatal("command_id above 128 bytes was accepted")
}
}
func TestDrawBatchRejectsAggregateWorkAcrossTargets(t *testing.T) {
repository := &batchLimitRepository{}
svc := New(repository)
commands := make([]domain.DrawCommand, 0, 6)
for index := 0; index < 6; index++ {
commands = append(commands, domain.DrawCommand{
CommandID: fmt.Sprintf("cmd-lucky-target-%d", index), UserID: 1, TargetUserID: int64(index + 10),
DeviceID: "device-1", RoomID: "room-1", AnchorID: "2", GiftID: "rose",
GiftCount: 999, CoinSpent: 999,
})
}
if _, err := svc.DrawBatch(context.Background(), commands); !xerr.IsCode(err, xerr.InvalidArgument) {
t.Fatalf("5994 aggregate draw units should be rejected, got %v", err)
}
if repository.batchCalls != 0 {
t.Fatalf("oversized batch reached repository %d times", repository.batchCalls)
}
if _, err := svc.DrawBatch(context.Background(), commands[:5]); err != nil {
t.Fatalf("4995 aggregate draw units should be accepted: %v", err)
}
if repository.batchCalls != 1 {
t.Fatalf("accepted batch repository calls=%d, want 1", repository.batchCalls)
}
}

View File

@ -12,6 +12,7 @@ import (
"time"
"hyapp/pkg/appcode"
"hyapp/pkg/giftlimits"
"hyapp/pkg/logx"
"hyapp/pkg/tencentim"
"hyapp/pkg/xerr"
@ -133,11 +134,19 @@ func (s *Service) DrawBatch(ctx context.Context, cmds []domain.DrawCommand) ([]d
return nil, xerr.New(xerr.InvalidArgument, "lucky gift batch draw commands are required")
}
normalized := make([]domain.DrawCommand, 0, len(cmds))
totalUnits := int64(0)
for _, cmd := range cmds {
item, err := s.normalizeDrawCommand(cmd)
if err != nil {
return nil, err
}
units := int64(item.GiftCount)
if units > giftlimits.MaxTotalGiftUnits-totalUnits {
// BatchExecute 的每个 command 对应一个收礼目标;逐项 <=999 仍可能被大量目标放大。
// 在进入 repository 的共享奖池事务前按所有目标求和,和 Gateway/Room/Wallet 的总工作量上限保持一致。
return nil, xerr.New(xerr.InvalidArgument, "lucky gift batch exceeds total work limit")
}
totalUnits += units
normalized = append(normalized, item)
}
// 批量送礼的返奖不能阻塞 room-service 主链路repository 只落 draw fact 和 settlement outboxworker 后续负责 wallet/IM。
@ -208,12 +217,17 @@ func (s *Service) normalizeDrawCommand(cmd domain.DrawCommand) (domain.DrawComma
cmd.DeviceID = strings.TrimSpace(cmd.DeviceID)
cmd.RoomID = strings.TrimSpace(cmd.RoomID)
cmd.AnchorID = strings.TrimSpace(cmd.AnchorID)
if cmd.CommandID == "" || cmd.UserID <= 0 || cmd.DeviceID == "" || cmd.RoomID == "" || cmd.AnchorID == "" || cmd.GiftID == "" || cmd.CoinSpent <= 0 {
if !giftlimits.ValidCommandID(cmd.CommandID) || cmd.UserID <= 0 || cmd.DeviceID == "" || cmd.RoomID == "" || cmd.AnchorID == "" || cmd.GiftID == "" || cmd.CoinSpent <= 0 {
return domain.DrawCommand{}, xerr.New(xerr.InvalidArgument, "lucky gift draw command is incomplete")
}
if cmd.TargetUserID < 0 || cmd.GiftCount <= 0 {
if cmd.TargetUserID < 0 {
return domain.DrawCommand{}, xerr.New(xerr.InvalidArgument, "lucky gift draw metadata is invalid")
}
if _, ok := giftlimits.WorkUnits(int64(cmd.GiftCount), 1); !ok {
// One lucky draw command is per target; multi-target room sends are split before this owner.
// The cap prevents a direct internal caller from allocating unbounded per-unit RNG/write work.
return domain.DrawCommand{}, xerr.New(xerr.InvalidArgument, "lucky gift count exceeds command limit")
}
if cmd.PaidAtMS <= 0 {
// paid_at_ms 是扣费事实时间,缺失时用 UTC 服务端时间补齐,避免本地时区污染 RTP 窗口。
cmd.PaidAtMS = s.now().UTC().UnixMilli()

View File

@ -1171,10 +1171,21 @@ func luckyAggregateDrawResults(cmd domain.DrawCommand, results []domain.DrawResu
aggregate := results[0]
aggregate.CommandID = cmd.CommandID
aggregate.DrawIDs = make([]string, 0, len(results))
for _, result := range results {
aggregate.Hits = make([]domain.DrawHit, 0)
for index, result := range results {
if strings.TrimSpace(result.DrawID) != "" {
aggregate.DrawIDs = append(aggregate.DrawIDs, result.DrawID)
}
if result.EffectiveRewardCoins > 0 {
// gift_index 从 1 开始,对齐 Flutter 组合礼物的点击/数量序号;只传中奖项,响应大小由中奖数而不是 gift_count 决定。
aggregate.Hits = append(aggregate.Hits, domain.DrawHit{
GiftIndex: int32(index + 1),
DrawID: result.DrawID,
SelectedTierID: result.SelectedTierID,
MultiplierPPM: result.MultiplierPPM,
RewardCoins: result.EffectiveRewardCoins,
})
}
}
if len(results) == 1 {
return aggregate

View File

@ -56,6 +56,20 @@ func TestLuckyBasePoolInCreditsFullPoolRateToSinglePool(t *testing.T) {
}
}
func TestLuckyAggregateDrawResultsKeepsOnlyWinningPositions(t *testing.T) {
result := luckyAggregateDrawResults(domain.DrawCommand{CommandID: "cmd-combo"}, []domain.DrawResult{
{DrawID: "draw-1", SelectedTierID: "2x", MultiplierPPM: 2_000_000, EffectiveRewardCoins: 20},
{DrawID: "draw-2", SelectedTierID: "0x", MultiplierPPM: 0, EffectiveRewardCoins: 0},
{DrawID: "draw-3", SelectedTierID: "5x", MultiplierPPM: 5_000_000, EffectiveRewardCoins: 50},
})
if len(result.Hits) != 2 {
t.Fatalf("only winning sub-draws should be returned, got %+v", result.Hits)
}
if result.Hits[0].GiftIndex != 1 || result.Hits[0].DrawID != "draw-1" || result.Hits[1].GiftIndex != 3 || result.Hits[1].RewardCoins != 50 {
t.Fatalf("winning positions mismatch: %+v", result.Hits)
}
}
func TestMergeLuckyBasePoolsMigration(t *testing.T) {
caller := mysqlschema.CallerFile(t, 1)
schema := mysqlschema.New(t, mysqlschema.Config{

View File

@ -309,7 +309,7 @@ func luckyRuleConfigToProto(config domain.RuleConfig) *luckygiftv1.LuckyGiftRule
}
func luckyDrawResultToProto(result domain.DrawResult) *luckygiftv1.LuckyGiftDrawResult {
return &luckygiftv1.LuckyGiftDrawResult{
response := &luckygiftv1.LuckyGiftDrawResult{
DrawId: result.DrawID,
CommandId: result.CommandID,
AppCode: result.AppCode,
@ -334,6 +334,13 @@ func luckyDrawResultToProto(result domain.DrawResult) *luckygiftv1.LuckyGiftDraw
WalletTransactionId: result.WalletTransactionID,
CoinBalanceAfter: result.CoinBalanceAfter,
}
for _, hit := range result.Hits {
response.Hits = append(response.Hits, &luckygiftv1.LuckyGiftHit{
GiftIndex: hit.GiftIndex, DrawId: hit.DrawID, SelectedTierId: hit.SelectedTierID,
MultiplierPpm: hit.MultiplierPPM, RewardCoins: hit.RewardCoins,
})
}
return response
}
func externalDrawResultToProto(result domain.ExternalDrawResult) *luckygiftv1.ExternalGiftDrawResponse {

View File

@ -181,6 +181,13 @@ func New(cfg config.Config) (*App, error) {
mqConsumers = append(mqConsumers, consumer)
}
if cfg.RocketMQ.RoomOutbox.Enabled {
roomTagExpression, err := roommq.LegacyCompatibleTagExpression(roommq.EventTypeRoomUserKicked)
if err != nil {
shutdownConsumers(mqConsumers)
_ = listener.Close()
_ = store.Close()
return nil, err
}
consumer, err := rocketmqx.NewConsumer(rocketMQConsumerConfig(cfg.RocketMQ, cfg.RocketMQ.RoomOutbox.ConsumerGroup, cfg.RocketMQ.RoomOutbox.ConsumerMaxReconsumeTimes))
if err != nil {
shutdownConsumers(mqConsumers)
@ -188,7 +195,7 @@ func New(cfg config.Config) (*App, error) {
_ = store.Close()
return nil, err
}
if err := consumer.Subscribe(cfg.RocketMQ.RoomOutbox.Topic, roommq.TagRoomOutboxEvent, func(ctx context.Context, message rocketmqx.ConsumedMessage) error {
if err := consumer.Subscribe(cfg.RocketMQ.RoomOutbox.Topic, roomTagExpression, func(ctx context.Context, message rocketmqx.ConsumedMessage) error {
envelope, _, err := roommq.DecodeRoomOutboxMessage(message.Body)
if err != nil {
return err

View File

@ -64,6 +64,8 @@ rocketmq:
namespace: ""
send_timeout: "3s"
retry: 2
# Phase A 保持 legacy所有 room consumer 完成兼容滚动后才允许切换为 event_type。
room_event_tag_mode: "legacy"
room_outbox:
enabled: true
topic: "hyapp_room_outbox"

View File

@ -74,6 +74,8 @@ rocketmq:
namespace: "hyapp-prod"
send_timeout: "3s"
retry: 2
# Phase A 必须保持 legacyPhase B 经消费者订阅和存量积压验证后,仅将该值切为 event_type。
room_event_tag_mode: "legacy"
room_outbox:
enabled: true
topic: "hyapp_room_outbox"

View File

@ -67,6 +67,8 @@ rocketmq:
namespace: ""
send_timeout: "3s"
retry: 2
# Phase A 保持 legacy确认所有 room consumer 已部署 legacy||typed 订阅后Phase B 才改为 event_type。
room_event_tag_mode: "legacy"
room_outbox:
enabled: true
topic: "hyapp_room_outbox"

View File

@ -143,12 +143,35 @@ CREATE TABLE IF NOT EXISTS room_command_log (
owner_node_id VARCHAR(128) NOT NULL DEFAULT '' COMMENT '房主节点 ID',
lease_token VARCHAR(128) NOT NULL DEFAULT '' COMMENT '租约令牌',
payload LONGBLOB NOT NULL COMMENT '业务负载快照',
result_payload LONGBLOB NULL COMMENT '首次提交的 typed 结果,用于幂等原样重放',
replayable BOOLEAN NOT NULL COMMENT '是否可重放',
created_at_ms BIGINT NOT NULL COMMENT '创建时间UTC epoch ms',
UNIQUE KEY uk_room_command (app_code, room_id, command_id),
KEY idx_room_command_replay (app_code, room_id, room_version)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='Room Cell 命令日志表';
CREATE TABLE IF NOT EXISTS room_gift_operations (
app_code VARCHAR(32) NOT NULL COMMENT '应用编码,用于多租户隔离',
room_id VARCHAR(64) NOT NULL COMMENT '房间 ID',
command_id VARCHAR(128) NOT NULL COMMENT '送礼全链路幂等 ID',
actor_user_id BIGINT NOT NULL COMMENT '送礼用户 ID',
command_payload LONGBLOB NOT NULL COMMENT '规范化 Room 命令,供载荷冲突校验',
request_payload LONGBLOB NOT NULL COMMENT '原始 SendGiftRequest protobuf供服务端恢复',
status VARCHAR(32) NOT NULL COMMENT 'pending/debited/drawn/committed/compensated',
billing_payload LONGBLOB NULL COMMENT '钱包稳定回执 protobuf',
lucky_payload LONGBLOB NULL COMMENT '幸运礼物聚合结果 protobuf',
result_payload LONGBLOB NULL COMMENT '首次提交的 SendGiftResultV2 protobuf',
last_error TEXT NULL COMMENT '最近一次恢复错误',
retry_count INT NOT NULL DEFAULT 0 COMMENT '服务端恢复次数',
next_retry_at_ms BIGINT NOT NULL DEFAULT 0 COMMENT '下次允许恢复时间UTC epoch ms',
worker_id VARCHAR(128) NOT NULL DEFAULT '' COMMENT '当前恢复 worker',
lock_until_ms BIGINT 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',
PRIMARY KEY (app_code, room_id, command_id),
KEY idx_room_gift_operation_recovery (status, next_retry_at_ms, lock_until_ms, updated_at_ms)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='送礼跨钱包抽奖和房间提交 Saga';
CREATE TABLE IF NOT EXISTS room_outbox (
app_code VARCHAR(32) NOT NULL COMMENT '应用编码,用于多租户隔离',
event_id VARCHAR(128) NOT NULL COMMENT '事件幂等 ID',

View File

@ -0,0 +1,26 @@
-- SendGift V2 需要把首次成功结果和扣款后的恢复状态固化在 room-service owner 库。
-- 线上先执行该迁移再发布新 room-service旧版本不会读取新增列和表迁移可向前兼容。
ALTER TABLE room_command_log
ADD COLUMN result_payload LONGBLOB NULL COMMENT '首次提交的 typed 结果,用于幂等原样重放' AFTER payload;
CREATE TABLE IF NOT EXISTS room_gift_operations (
app_code VARCHAR(32) NOT NULL COMMENT '应用编码,用于多租户隔离',
room_id VARCHAR(64) NOT NULL COMMENT '房间 ID',
command_id VARCHAR(128) NOT NULL COMMENT '送礼全链路幂等 ID',
actor_user_id BIGINT NOT NULL COMMENT '送礼用户 ID',
command_payload LONGBLOB NOT NULL COMMENT '规范化 Room 命令,供载荷冲突校验',
request_payload LONGBLOB NOT NULL COMMENT '原始 SendGiftRequest protobuf供服务端恢复',
status VARCHAR(32) NOT NULL COMMENT 'pending/debited/drawn/committed/compensated',
billing_payload LONGBLOB NULL COMMENT '钱包稳定回执 protobuf',
lucky_payload LONGBLOB NULL COMMENT '幸运礼物聚合结果 protobuf',
result_payload LONGBLOB NULL COMMENT '首次提交的 SendGiftResultV2 protobuf',
last_error TEXT NULL COMMENT '最近一次恢复错误',
retry_count INT NOT NULL DEFAULT 0 COMMENT '服务端恢复次数',
next_retry_at_ms BIGINT NOT NULL DEFAULT 0 COMMENT '下次允许恢复时间UTC epoch ms',
worker_id VARCHAR(128) NOT NULL DEFAULT '' COMMENT '当前恢复 worker',
lock_until_ms BIGINT 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',
PRIMARY KEY (app_code, room_id, command_id),
KEY idx_room_gift_operation_recovery (status, next_retry_at_ms, lock_until_ms, updated_at_ms)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='送礼跨钱包抽奖和房间提交 Saga';

View File

@ -208,7 +208,7 @@ func New(cfg config.Config) (*App, error) {
return nil, err
}
mqProducers = append(mqProducers, outboxProducer)
outboxPublishers = append(outboxPublishers, integration.NewRocketMQOutboxPublisher(outboxProducer, cfg.RocketMQ.RoomOutbox.Topic))
outboxPublishers = append(outboxPublishers, integration.NewRocketMQOutboxPublisher(outboxProducer, cfg.RocketMQ.RoomOutbox.Topic, cfg.RocketMQ.RoomEventTagMode))
}
if cfg.RocketMQ.RocketLaunch.Enabled {
rocketProducer, err := rocketmqx.NewProducer(rocketMQProducerConfig(cfg.RocketMQ, cfg.RocketMQ.RocketLaunch.ProducerGroup))
@ -387,6 +387,11 @@ func (a *App) Run() error {
// 火箭燃料进度允许 1 秒展示延迟;普通房写主 outbox机器人展示到期后 best-effort 直发腾讯 IM。
a.service.RunRoomRocketProgressFlushWorker(ctx, 100*time.Millisecond)
})
a.workers.Go(func(ctx context.Context) {
// SendGift 跨钱包、幸运礼物和 Room Cell恢复 worker 用稳定 command_id 主动完成扣款未知/已扣款操作,
// 客户端断线或不再重试都不能留下永久扣款无房间事实。
a.service.RunGiftOperationRecoveryWorker(ctx, time.Second)
})
if a.cfg.RoomListCacheRefreshInterval > 0 {
// Redis 发现页缓存是 MySQL owner 表的展示读模型,启动后先 warmup再周期性修复 best-effort 增量丢失。
a.workers.Go(func(ctx context.Context) {

View File

@ -10,6 +10,7 @@ import (
"hyapp/pkg/configx"
"hyapp/pkg/logx"
"hyapp/pkg/roommq"
"hyapp/pkg/tencentim"
"hyapp/pkg/tencentrtc"
)
@ -139,16 +140,19 @@ type OutboxRetentionConfig struct {
// RocketMQConfig 描述 room-service 使用的 RocketMQ 集群和业务 topic。
type RocketMQConfig struct {
Enabled bool `yaml:"enabled"`
NameServers []string `yaml:"name_servers"`
NameServerDomain string `yaml:"name_server_domain"`
AccessKey string `yaml:"access_key"`
SecretKey string `yaml:"secret_key"`
SecurityToken string `yaml:"security_token"`
Namespace string `yaml:"namespace"`
VIPChannel bool `yaml:"vip_channel"`
SendTimeout time.Duration `yaml:"send_timeout"`
Retry int `yaml:"retry"`
Enabled bool `yaml:"enabled"`
NameServers []string `yaml:"name_servers"`
NameServerDomain string `yaml:"name_server_domain"`
AccessKey string `yaml:"access_key"`
SecretKey string `yaml:"secret_key"`
SecurityToken string `yaml:"security_token"`
Namespace string `yaml:"namespace"`
VIPChannel bool `yaml:"vip_channel"`
SendTimeout time.Duration `yaml:"send_timeout"`
Retry int `yaml:"retry"`
// RoomEventTagMode 是 room topic producer 的两阶段发布开关。legacy 保持旧
// consumer 可见event_type 只能在全部 consumer 完成兼容订阅滚动后启用。
RoomEventTagMode roommq.EventTagMode `yaml:"room_event_tag_mode"`
RoomOutbox RoomOutboxMQConfig `yaml:"room_outbox"`
RocketLaunch RocketLaunchMQConfig `yaml:"rocket_launch"`
UserOutbox UserOutboxMQConfig `yaml:"user_outbox"`
@ -341,9 +345,10 @@ func defaultOutboxWorkerConfig() OutboxWorkerConfig {
func defaultRocketMQConfig() RocketMQConfig {
return RocketMQConfig{
Enabled: false,
SendTimeout: 3 * time.Second,
Retry: 2,
Enabled: false,
SendTimeout: 3 * time.Second,
Retry: 2,
RoomEventTagMode: roommq.EventTagModeLegacy,
RoomOutbox: RoomOutboxMQConfig{
Enabled: false,
Topic: "hyapp_room_outbox",
@ -552,6 +557,17 @@ func normalizeRocketMQConfig(cfg RocketMQConfig, publishMode string) (RocketMQCo
if cfg.Retry < 0 {
cfg.Retry = defaults.Retry
}
cfg.RoomEventTagMode = roommq.EventTagMode(strings.ToLower(strings.TrimSpace(string(cfg.RoomEventTagMode))))
if cfg.RoomEventTagMode == "" {
// 旧线上 YAML 没有此字段时必须继续发 legacy Tag切 Phase B 必须是一次
// 显式配置变更,不能由普通代码滚动发布顺带改变 MQ 可见性。
cfg.RoomEventTagMode = defaults.RoomEventTagMode
}
switch cfg.RoomEventTagMode {
case roommq.EventTagModeLegacy, roommq.EventTagModeEventType:
default:
return RocketMQConfig{}, fmt.Errorf("rocketmq.room_event_tag_mode must be legacy or event_type")
}
if cfg.RoomOutbox.Topic = strings.TrimSpace(cfg.RoomOutbox.Topic); cfg.RoomOutbox.Topic == "" {
cfg.RoomOutbox.Topic = defaults.RoomOutbox.Topic
}

View File

@ -2,8 +2,13 @@
package config
import (
"os"
"path/filepath"
"strings"
"testing"
"time"
"hyapp/pkg/roommq"
)
// TestLoad 验证 room-service 的本地 config.yaml 能被正常读取。
@ -47,6 +52,44 @@ func TestLoad(t *testing.T) {
if !cfg.RocketMQ.Enabled || !cfg.RocketMQ.RoomOutbox.Enabled || cfg.RocketMQ.RoomOutbox.TencentIMConsumerEnabled || !cfg.RocketMQ.RocketLaunch.Enabled || !cfg.RocketMQ.UserOutbox.Enabled {
t.Fatalf("local config must enable room MQ fanout and disable MQ IM bridge: %+v", cfg.RocketMQ)
}
if cfg.RocketMQ.RoomEventTagMode != roommq.EventTagModeLegacy {
t.Fatalf("Phase A config must publish legacy room tags: %q", cfg.RocketMQ.RoomEventTagMode)
}
}
func TestRoomEventTagModeDefaultsAndValidation(t *testing.T) {
if got := Default().RocketMQ.RoomEventTagMode; got != roommq.EventTagModeLegacy {
t.Fatalf("default room event tag mode = %q, want %q", got, roommq.EventTagModeLegacy)
}
missing, err := Load(writeRoomConfig(t, "rocketmq:\n enabled: false\n"))
if err != nil {
t.Fatalf("Load missing room tag mode failed: %v", err)
}
if missing.RocketMQ.RoomEventTagMode != roommq.EventTagModeLegacy {
t.Fatalf("missing room_event_tag_mode = %q", missing.RocketMQ.RoomEventTagMode)
}
typed, err := Load(writeRoomConfig(t, "rocketmq:\n enabled: false\n room_event_tag_mode: ' EVENT_TYPE '\n"))
if err != nil {
t.Fatalf("Load Phase B room tag mode failed: %v", err)
}
if typed.RocketMQ.RoomEventTagMode != roommq.EventTagModeEventType {
t.Fatalf("room_event_tag_mode = %q, want %q", typed.RocketMQ.RoomEventTagMode, roommq.EventTagModeEventType)
}
if _, err := Load(writeRoomConfig(t, "rocketmq:\n enabled: false\n room_event_tag_mode: wildcard\n")); err == nil || !strings.Contains(err.Error(), "room_event_tag_mode") {
t.Fatalf("Load unknown room_event_tag_mode error = %v", err)
}
}
func writeRoomConfig(t *testing.T, body string) string {
t.Helper()
path := filepath.Join(t.TempDir(), "config.yaml")
if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
t.Fatalf("write room config: %v", err)
}
return path
}
// TestLoadTencentExample 验证线上腾讯云 IM 模板能覆盖本地默认配置。
@ -83,6 +126,9 @@ func TestLoadTencentExample(t *testing.T) {
if cfg.OutboxWorker.PublishMode != OutboxPublishModeMQ || !cfg.RocketMQ.RoomOutbox.Enabled || !cfg.RocketMQ.RocketLaunch.Enabled {
t.Fatalf("tencent example must route room outbox and rocket launch through RocketMQ: mode=%s mq=%+v", cfg.OutboxWorker.PublishMode, cfg.RocketMQ)
}
if cfg.RocketMQ.RoomEventTagMode != roommq.EventTagModeLegacy {
t.Fatalf("tencent example must remain in Phase A until consumers are verified: %q", cfg.RocketMQ.RoomEventTagMode)
}
}
func TestLoadDockerConfigDoesNotStartRoomIMBridgeWhenOutboxUsesMQ(t *testing.T) {
@ -100,6 +146,9 @@ func TestLoadDockerConfigDoesNotStartRoomIMBridgeWhenOutboxUsesMQ(t *testing.T)
if !cfg.RocketMQ.RoomOutbox.Enabled {
t.Fatalf("docker config must enable room outbox MQ")
}
if cfg.RocketMQ.RoomEventTagMode != roommq.EventTagModeLegacy {
t.Fatalf("docker config must remain in Phase A: %q", cfg.RocketMQ.RoomEventTagMode)
}
if cfg.RocketMQ.RoomOutbox.TencentIMConsumerEnabled {
t.Fatalf("docker mq room outbox must not start Tencent IM bridge consumer")
}

View File

@ -2,8 +2,10 @@ package integration
import (
"context"
"fmt"
"strconv"
"google.golang.org/protobuf/proto"
roomeventsv1 "hyapp.local/api/proto/events/room/v1"
"hyapp/pkg/rocketmqx"
"hyapp/pkg/roommq"
@ -13,37 +15,69 @@ import (
type RocketMQOutboxPublisher struct {
producer *rocketmqx.Producer
topic string
tagMode roommq.EventTagMode
}
// NewRocketMQOutboxPublisher creates a room_outbox fanout publisher.
func NewRocketMQOutboxPublisher(producer *rocketmqx.Producer, topic string) *RocketMQOutboxPublisher {
return &RocketMQOutboxPublisher{producer: producer, topic: topic}
func NewRocketMQOutboxPublisher(producer *rocketmqx.Producer, topic string, tagMode roommq.EventTagMode) *RocketMQOutboxPublisher {
return &RocketMQOutboxPublisher{producer: producer, topic: topic, tagMode: tagMode}
}
// PublishOutboxEvent implements OutboxPublisher.
func (p *RocketMQOutboxPublisher) PublishOutboxEvent(ctx context.Context, envelope *roomeventsv1.EventEnvelope) error {
body, err := roommq.EncodeRoomOutboxMessage(envelope)
message, err := roomOutboxRocketMessage(p.topic, p.tagMode, envelope)
if err != nil {
return err
}
return p.producer.SendSync(ctx, rocketmqx.Message{
Topic: p.topic,
Tag: roommq.TagRoomOutboxEvent,
Keys: []string{envelope.GetEventId(), envelope.GetRoomId()},
return p.producer.SendSync(ctx, message)
}
// roomOutboxRocketMessage keeps envelope.event_type canonical in both migration phases.
// Phase A publishes the legacy Tag, while explicit Phase B uses the exact event_type;
// the body stays byte-compatible so old replay tools and handlers require no protocol fork.
func roomOutboxRocketMessage(topic string, tagMode roommq.EventTagMode, envelope *roomeventsv1.EventEnvelope) (rocketmqx.Message, error) {
if envelope == nil {
return rocketmqx.Message{}, fmt.Errorf("room outbox envelope is required")
}
eventTag, err := roommq.EventTypeTag(envelope.GetEventType())
if err != nil {
return rocketmqx.Message{}, err
}
publishTag := roommq.TagRoomOutboxEvent
switch tagMode {
case "", roommq.EventTagModeLegacy:
// 空值覆盖旧测试构造和旧配置;只有显式 event_type 才允许进入 Phase B。
case roommq.EventTagModeEventType:
publishTag = eventTag
default:
return rocketmqx.Message{}, fmt.Errorf("unsupported room event tag mode %q", tagMode)
}
// outbox 历史数据可能含首尾空白Tag 与消息体必须使用同一规范 event_type
// 但不能原地修改 repository 持有的 envelope避免 composite publisher 看到副作用。
canonicalEnvelope := proto.Clone(envelope).(*roomeventsv1.EventEnvelope)
canonicalEnvelope.EventType = eventTag
body, err := roommq.EncodeRoomOutboxMessage(canonicalEnvelope)
if err != nil {
return rocketmqx.Message{}, err
}
return rocketmqx.Message{
Topic: topic,
Tag: publishTag,
Keys: []string{canonicalEnvelope.GetEventId(), canonicalEnvelope.GetRoomId()},
Body: body,
Properties: map[string]string{
"message_type": roommq.MessageTypeRoomOutboxEvent,
"app_code": envelope.GetAppCode(),
"room_id": envelope.GetRoomId(),
"event_id": envelope.GetEventId(),
"event_type": envelope.GetEventType(),
"room_version": strconv.FormatInt(envelope.GetRoomVersion(), 10),
"occurred_at_ms": strconv.FormatInt(envelope.GetOccurredAtMs(), 10),
"app_code": canonicalEnvelope.GetAppCode(),
"room_id": canonicalEnvelope.GetRoomId(),
"event_id": canonicalEnvelope.GetEventId(),
"event_type": eventTag,
"room_version": strconv.FormatInt(canonicalEnvelope.GetRoomVersion(), 10),
"occurred_at_ms": strconv.FormatInt(canonicalEnvelope.GetOccurredAtMs(), 10),
"source_database": "hyapp_room",
"source_table": "room_outbox",
"delivery_version": "1",
},
})
}, nil
}
// RocketMQRocketLaunchScheduler publishes exact-time rocket wakeup messages.

View File

@ -0,0 +1,64 @@
package integration
import (
"testing"
roomeventsv1 "hyapp.local/api/proto/events/room/v1"
"hyapp/pkg/roommq"
)
func TestRoomOutboxRocketMessageUsesConfiguredTagMode(t *testing.T) {
tests := []struct {
name string
mode roommq.EventTagMode
wantTag string
}{
{name: "missing config remains legacy", mode: "", wantTag: roommq.TagRoomOutboxEvent},
{name: "phase a publishes legacy", mode: roommq.EventTagModeLegacy, wantTag: roommq.TagRoomOutboxEvent},
{name: "phase b publishes event type", mode: roommq.EventTagModeEventType, wantTag: roommq.EventTypeRoomGiftSent},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
message, err := roomOutboxRocketMessage("hyapp_room_outbox", test.mode, validRoomOutboxEnvelope())
if err != nil {
t.Fatalf("roomOutboxRocketMessage failed: %v", err)
}
if message.Tag != test.wantTag {
t.Fatalf("message tag = %q, want %q", message.Tag, test.wantTag)
}
envelope, _, err := roommq.DecodeRoomOutboxMessage(message.Body)
if err != nil {
t.Fatalf("DecodeRoomOutboxMessage failed: %v", err)
}
if envelope.GetEventType() != roommq.EventTypeRoomGiftSent {
t.Fatalf("body event_type = %q", envelope.GetEventType())
}
if message.Topic != "hyapp_room_outbox" || len(message.Keys) != 2 {
t.Fatalf("message routing metadata mismatch: %+v", message)
}
})
}
}
func TestRoomOutboxRocketMessageRejectsUnsupportedModeOrUnsafeTag(t *testing.T) {
if _, err := roomOutboxRocketMessage("hyapp_room_outbox", roommq.EventTagMode("all"), validRoomOutboxEnvelope()); err == nil {
t.Fatal("roomOutboxRocketMessage unexpectedly accepted unsupported tag mode")
}
envelope := validRoomOutboxEnvelope()
envelope.EventType = "RoomGiftSent || *"
if _, err := roomOutboxRocketMessage("hyapp_room_outbox", roommq.EventTagModeLegacy, envelope); err == nil {
t.Fatal("roomOutboxRocketMessage unexpectedly accepted unsafe event_type")
}
}
func validRoomOutboxEnvelope() *roomeventsv1.EventEnvelope {
return &roomeventsv1.EventEnvelope{
AppCode: "lalu",
EventId: "event-1",
EventType: " " + roommq.EventTypeRoomGiftSent + " ",
RoomId: "room-1",
RoomVersion: 9,
OccurredAtMs: 1_700_000_000_000,
Body: []byte{1, 2, 3},
}
}

View File

@ -444,6 +444,10 @@ type SendGift struct {
Source string `json:"source,omitempty"`
// DisplayMode 只控制客户端展示事件形态batch 表示保留逐目标事实,但房间 IM 合并为单条多人展示消息。
DisplayMode string `json:"display_mode,omitempty"`
// ComboSessionID/BatchSeq 描述 Flutter 微批顺序;它们进入 command_id 载荷冲突校验,
// 但不能替代 Base.CommandID 作为钱包、抽奖和房间提交的唯一幂等键。
ComboSessionID string `json:"combo_session_id,omitempty"`
BatchSeq int64 `json:"batch_seq,omitempty"`
// SenderRegionID 是送礼用户所属区域,由 gateway 从 user-service 注入。
SenderRegionID int64 `json:"sender_region_id,omitempty"`
// SenderCountryID 是送礼用户所属国家,由 gateway 从 user-service 注入,统计服务不能用房间区域反推。
@ -663,6 +667,11 @@ func IdempotencyPayload(payload []byte) ([]byte, error) {
delete(values, "target_is_host")
delete(values, "target_host_region_id")
delete(values, "target_agency_owner_user_id")
// sender country/region are also gateway-resolved snapshots. The first gift_operation persists
// their exact values for settlement and statistics, but a later retry of the same client body must
// not conflict merely because the user changed profile between timeout and replay.
delete(values, "sender_region_id")
delete(values, "sender_country_id")
// SendGift 的以下字段由 wallet-service 结算后写入 command log不属于客户端请求幂等语义。
delete(values, "billing_receipt_id")
delete(values, "coin_spent")

View File

@ -0,0 +1,45 @@
package command
import (
"bytes"
"testing"
)
func TestSendGiftIdempotencyIgnoresGatewayResolvedSnapshots(t *testing.T) {
base := SendGift{
Base: Base{CommandID: "combo-1_b1", Room: "room-1", ActorID: 42},
TargetType: "user", TargetUserIDs: []int64{43}, TargetUserID: 43,
GiftID: "rose", GiftCount: 9, ComboSessionID: "combo-1", BatchSeq: 1,
SenderRegionID: 1001, SenderCountryID: 840,
TargetHostScopes: []GiftTargetHostScope{{TargetUserID: 43, TargetIsHost: true, TargetHostRegionID: 1001}},
SenderDisplayProfile: GiftDisplayProfile{UserID: 42, Username: "before"},
}
retry := base
retry.SenderRegionID = 2002
retry.SenderCountryID = 356
retry.TargetHostScopes = []GiftTargetHostScope{{TargetUserID: 43, TargetIsHost: false}}
retry.SenderDisplayProfile = GiftDisplayProfile{UserID: 42, Username: "after"}
left, err := IdempotencyPayloadForCommand(base)
if err != nil {
t.Fatalf("base payload: %v", err)
}
right, err := IdempotencyPayloadForCommand(retry)
if err != nil {
t.Fatalf("retry payload: %v", err)
}
if !bytes.Equal(left, right) {
t.Fatalf("gateway snapshot drift changed client idempotency semantics:\nleft=%s\nright=%s", left, right)
}
}
func TestSendGiftIdempotencyKeepsComboBatchIdentity(t *testing.T) {
base := SendGift{Base: Base{CommandID: "combo-1_b1", Room: "room-1", ActorID: 42}, TargetType: "user", TargetUserIDs: []int64{43}, TargetUserID: 43, GiftID: "rose", GiftCount: 1, ComboSessionID: "combo-1", BatchSeq: 1}
reused := base
reused.BatchSeq = 2
left, _ := IdempotencyPayloadForCommand(base)
right, _ := IdempotencyPayloadForCommand(reused)
if bytes.Equal(left, right) {
t.Fatal("same command_id reused for another combo batch was not detected")
}
}

View File

@ -83,6 +83,9 @@ func (s *Service) AdminDeleteRoom(ctx context.Context, req *roomv1.AdminDeleteRo
if current == nil {
return mutationResult{}, nil, xerr.New(xerr.NotFound, "room not found")
}
if err := s.ensureNoRecoverableGiftOperations(ctx, current.RoomID); err != nil {
return mutationResult{}, nil, err
}
// 删除不是“关闭”的另一种展示状态,而是后台确认移除房间恢复来源;状态只服务本次响应和内存 Cell 收尾。
current.Status = state.RoomStatusDeleted
current.OnlineUsers = map[int64]*state.RoomUserState{}
@ -187,6 +190,11 @@ func (s *Service) applyAdminRoomMutation(ctx context.Context, logCommand command
if current == nil {
return mutationResult{}, nil, xerr.New(xerr.NotFound, "room not found")
}
if cmd.Status != nil && (*cmd.Status == state.RoomStatusClosed || *cmd.Status == state.RoomStatusDeleted) {
if err := s.ensureNoRecoverableGiftOperations(ctx, current.RoomID); err != nil {
return mutationResult{}, nil, err
}
}
changed := applyAdminRoomPatch(current, cmd)
records := make([]outbox.Record, 0, 2)
result := mutationResult{

View File

@ -11,6 +11,11 @@ import (
roomv1 "hyapp.local/api/proto/room/v1"
)
const (
maxGiftTargetsPerCommand = 30
maxGiftComboSessionIDLength = 128
)
// SendGift 先同步调用 wallet再在 Room Cell 内完成热度、榜单和房间事件落地。
func (s *Service) SendGift(ctx context.Context, req *roomv1.SendGiftRequest) (*roomv1.SendGiftResponse, error) {
return s.sendGift(ctx, req, giftSendOptions{})
@ -32,8 +37,19 @@ type giftSendOptions struct {
}
func (s *Service) sendGift(ctx context.Context, req *roomv1.SendGiftRequest, options giftSendOptions) (*roomv1.SendGiftResponse, error) {
return s.sendGiftWithRecovery(ctx, req, options, GiftOperation{})
}
func (s *Service) sendGiftWithRecovery(ctx context.Context, req *roomv1.SendGiftRequest, options giftSendOptions, recovery GiftOperation) (*roomv1.SendGiftResponse, error) {
ctx = contextFromMeta(ctx, req.GetMeta())
flow := newGiftFlow(s, ctx, req, options)
if recovery.CommandID != "" {
if err := flow.resumeGiftOperation(recovery); err != nil {
return nil, err
}
} else if err := flow.loadGiftOperation(); err != nil {
return nil, err
}
result, err := s.mutateRoom(ctx, flow.cmd, true, func(now time.Time, current *state.RoomState, localRank *rank.LocalRank) (mutationResult, []outbox.Record, error) {
// 阶段顺序保持原 sendGift 同步主链路:先校验房间态,再取房间事实,再扣费/抽奖,最后提交 Room Cell 状态和构造事件。
@ -43,6 +59,9 @@ func (s *Service) sendGift(ctx context.Context, req *roomv1.SendGiftRequest, opt
if err := flow.prepareRoomFacts(); err != nil {
return mutationResult{}, nil, err
}
if err := flow.ensureGiftOperation(now); err != nil {
return mutationResult{}, nil, err
}
if err := flow.debitAndSettle(now); err != nil {
return mutationResult{}, nil, err
}
@ -52,6 +71,7 @@ func (s *Service) sendGift(ctx context.Context, req *roomv1.SendGiftRequest, opt
return flow.buildMutationResult(now, current)
})
if err != nil {
flow.handleGiftOperationFailure(err)
return nil, err
}
@ -67,5 +87,6 @@ func (s *Service) sendGift(ctx context.Context, req *roomv1.SendGiftRequest, opt
CoinBalanceAfter: result.coinBalanceAfter,
GiftIncomeBalanceAfter: result.giftIncomeBalanceAfter,
BatchDisplay: result.batchDisplay,
V2: result.giftV2,
}, nil
}

View File

@ -2,10 +2,12 @@ package service
import (
"context"
"crypto/sha256"
"fmt"
walletv1 "hyapp.local/api/proto/wallet/v1"
"hyapp/pkg/appcode"
"hyapp/pkg/giftlimits"
"hyapp/pkg/xerr"
"hyapp/services/room-service/internal/room/command"
)
@ -122,8 +124,15 @@ func giftTargetCommandID(commandID string, targetUserID int64, targetCount int)
if targetCount <= 1 {
return commandID
}
// 钱包以 command_id 做幂等主键;多目标必须按接收方派生稳定子命令,才能在同一批内保存独立交易。
return fmt.Sprintf("%s:target:%d", commandID, targetUserID)
// 短主键沿用首版派生格式,保证滚动发布期间已经进入 saga 的请求仍命中原 wallet/lucky 幂等事实。
legacy := fmt.Sprintf("%s:target:%d", commandID, targetUserID)
if len(legacy) <= giftlimits.MaxCommandIDBytes {
return legacy
}
// room 主 command_id 本身允许占满 128 bytes不能再直接拼接 target。固定域分隔哈希同时绑定
// parent 和 target长度恒定且重试稳定wallet 子交易与 lucky draw 继续共用这一把幂等键。
digest := sha256.Sum256([]byte(fmt.Sprintf("room-gift-target-v2\x00%s\x00%d", commandID, targetUserID)))
return fmt.Sprintf("gift-target:v2:%x", digest)
}
func giftTargetBillingsFromBatch(resp *walletv1.BatchDebitGiftResponse, targetUserIDs []int64) ([]giftTargetBilling, error) {

View File

@ -0,0 +1,36 @@
package service
import (
"strings"
"testing"
"hyapp/pkg/giftlimits"
)
func TestGiftTargetCommandIDBoundsMaxLengthParentAndStaysDeterministic(t *testing.T) {
parent := strings.Repeat("p", giftlimits.MaxCommandIDBytes)
first := giftTargetCommandID(parent, 9001, 2)
retry := giftTargetCommandID(parent, 9001, 2)
otherTarget := giftTargetCommandID(parent, 9002, 2)
if first != retry {
t.Fatalf("same parent/target must derive a stable child command: %q != %q", first, retry)
}
if len(first) > giftlimits.MaxCommandIDBytes {
t.Fatalf("derived child command has %d bytes, max %d", len(first), giftlimits.MaxCommandIDBytes)
}
if first == otherTarget {
t.Fatalf("different targets must not share a child command: %q", first)
}
if !strings.HasPrefix(first, "gift-target:v2:") {
t.Fatalf("overflowing legacy key must use the bounded v2 derivation: %q", first)
}
}
func TestGiftTargetCommandIDPreservesCompatibleKeys(t *testing.T) {
if got := giftTargetCommandID("existing-command", 42, 1); got != "existing-command" {
t.Fatalf("single target command changed: %q", got)
}
if got := giftTargetCommandID("existing-command", 42, 2); got != "existing-command:target:42" {
t.Fatalf("existing bounded multi-target command changed: %q", got)
}
}

View File

@ -11,6 +11,7 @@ import (
roomv1 "hyapp.local/api/proto/room/v1"
walletv1 "hyapp.local/api/proto/wallet/v1"
"hyapp/pkg/appcode"
"hyapp/pkg/giftlimits"
"hyapp/pkg/tencentim"
"hyapp/pkg/xerr"
"hyapp/services/room-service/internal/room/command"
@ -52,6 +53,13 @@ type giftFlow struct {
batchDisplay *roomv1.SendGiftBatchDisplay
batchDisplayEnabled bool
suppressDirectIMEventTypes map[string]bool
// operationStarted 后任何失败都必须由服务端更新 saga客户端断线不能成为扣款后完成结算的必要条件。
operationStarted bool
debitCompleted bool
drawCompleted bool
recovering bool
operation GiftOperation
}
func newGiftFlow(s *Service, ctx context.Context, req *roomv1.SendGiftRequest, options giftSendOptions) *giftFlow {
@ -70,6 +78,8 @@ func newGiftFlow(s *Service, ctx context.Context, req *roomv1.SendGiftRequest, o
EntitlementID: strings.TrimSpace(req.GetEntitlementId()),
Source: strings.TrimSpace(req.GetSource()),
DisplayMode: displayMode,
ComboSessionID: strings.TrimSpace(req.GetComboSessionId()),
BatchSeq: req.GetBatchSeq(),
SenderCountryID: req.GetSenderCountryId(),
SenderRegionID: req.GetSenderRegionId(),
TargetIsHost: req.GetTargetIsHost(),
@ -96,9 +106,11 @@ func newGiftFlow(s *Service, ctx context.Context, req *roomv1.SendGiftRequest, o
}
func (f *giftFlow) validateBeforeDebit(current *state.RoomState) error {
if _, exists := current.OnlineUsers[f.cmd.ActorUserID()]; !exists {
// 送礼者必须在房间业务 presence 内;没有进入 Room Cell 的用户不能触发钱包扣费。
return xerr.New(xerr.NotFound, "sender not in room")
if !f.recovering {
if _, exists := current.OnlineUsers[f.cmd.ActorUserID()]; !exists {
// 送礼者必须在房间业务 presence 内;没有进入 Room Cell 的用户不能触发钱包扣费。
return xerr.New(xerr.NotFound, "sender not in room")
}
}
if f.cmd.TargetType != "user" {
// v1 HTTP 契约已预留 all_mic/all_room/couple账务拆单和房间表现策略补齐前先 fail-close。
@ -107,7 +119,10 @@ func (f *giftFlow) validateBeforeDebit(current *state.RoomState) error {
if len(f.cmd.TargetUserIDs) == 0 || f.cmd.TargetUserID <= 0 {
return xerr.New(xerr.InvalidArgument, "target_user_ids is required")
}
if f.batchDisplayEnabled {
if f.recovering {
// operation 只会在首次 presence/目标校验通过后创建;扣款未知或已完成时必须使用当时固化的实际目标继续结算,
// 不能因用户随后离房而永久卡住已扣款 saga。
} else if f.batchDisplayEnabled {
if len(f.cmd.RequestedTargetUserIDs) < 2 {
// batch-send 只承载“全部人/多人”语义;过滤后可以只剩一个真实送达目标,但原始请求不能退化成单人入口。
return xerr.New(xerr.InvalidArgument, "batch target_user_ids requires at least two users")
@ -124,7 +139,7 @@ func (f *giftFlow) validateBeforeDebit(current *state.RoomState) error {
}
}
}
if f.robot.Enabled {
if f.robot.Enabled && !f.recovering {
if err := requireRobotGiftParticipants(current, f.cmd.ActorUserID(), f.cmd.TargetUserIDs, f.robot.RobotUserIDs, !f.robot.RealRoomHeat); err != nil {
return err
}
@ -133,6 +148,19 @@ func (f *giftFlow) validateBeforeDebit(current *state.RoomState) error {
// 礼物 ID 和数量是钱包查服务端价格的最小输入,客户端不再提交礼物单价。
return xerr.New(xerr.InvalidArgument, "gift_id and gift_count must be valid")
}
if len(f.cmd.TargetUserIDs) > maxGiftTargetsPerCommand {
// 限制必须由 Room owner 再执行一次,不能只相信 gateway内部调用、旧网关或回放入口
// 都可能绕过 HTTP 层。gift_count 和目标数分别限制,避免分配异常大的账务请求。
return xerr.New(xerr.InvalidArgument, "gift command exceeds owner limits")
}
if _, ok := giftlimits.WorkUnits(int64(f.cmd.GiftCount), len(f.cmd.TargetUserIDs)); !ok {
// 幸运礼物按“数量 x 目标数”逐份推进奖池状态,真实成本不是 HTTP 请求数;总单位上限
// 防止一个合法外形的大批次独占 Room Cell 和 lucky-gift 数据库事务。
return xerr.New(xerr.InvalidArgument, "gift command units exceed owner limit")
}
if len(f.cmd.ComboSessionID) > maxGiftComboSessionIDLength || f.cmd.BatchSeq < 0 || (f.cmd.ComboSessionID != "" && f.cmd.BatchSeq <= 0) {
return xerr.New(xerr.InvalidArgument, "gift combo metadata is invalid")
}
if strings.EqualFold(f.cmd.Source, "bag") && strings.TrimSpace(f.cmd.EntitlementID) == "" {
// 背包送礼只改变扣费来源,不改变房间事件;因此必须在扣费前明确指定要扣的权益,不能让 wallet 猜测某个库存。
return xerr.New(xerr.InvalidArgument, "entitlement_id is required for bag gift")
@ -193,25 +221,31 @@ func (f *giftFlow) prepareRoomFacts() error {
}
f.roomMeta = roomMeta
if f.cmd.PoolID != "" && !f.cmd.SyntheticLuckyGift {
// 客户端显式传 pool_id 时必须先检查活动规则;规则未启用就拒绝,避免先扣费再发现不能抽奖。
if f.s.luckyGift == nil {
return xerr.New(xerr.Unavailable, "lucky gift service is not configured")
if f.recovering {
// saga 只在首次 CheckLuckyGift 已通过后创建;恢复阶段直接复用稳定 command_id 开奖,
// 避免规则临时下线把已经扣款的操作卡在预检查上。
f.luckyEnabled = true
} else {
// 客户端显式传 pool_id 时必须先检查活动规则;规则未启用就拒绝,避免先扣费再发现不能抽奖。
if f.s.luckyGift == nil {
return xerr.New(xerr.Unavailable, "lucky gift service is not configured")
}
checkResp, err := f.s.luckyGift.CheckLuckyGift(f.ctx, &luckygiftv1.CheckLuckyGiftRequest{
Meta: luckyGiftMetaFromRoom(f.ctx, f.req.GetMeta()),
UserId: f.cmd.ActorUserID(),
RoomId: f.cmd.RoomID(),
GiftId: f.cmd.GiftID,
PoolId: f.cmd.PoolID,
})
if err != nil {
return err
}
if checkResp == nil || !checkResp.GetEnabled() {
return xerr.New(xerr.RuleNotActive, "lucky gift rule is not active")
}
// Check 只说明“允许按该 pool 抽奖”,真实价格、区域可用性和扣费仍以 wallet-service 为准。
f.luckyEnabled = true
}
checkResp, err := f.s.luckyGift.CheckLuckyGift(f.ctx, &luckygiftv1.CheckLuckyGiftRequest{
Meta: luckyGiftMetaFromRoom(f.ctx, f.req.GetMeta()),
UserId: f.cmd.ActorUserID(),
RoomId: f.cmd.RoomID(),
GiftId: f.cmd.GiftID,
PoolId: f.cmd.PoolID,
})
if err != nil {
return err
}
if checkResp == nil || !checkResp.GetEnabled() {
return xerr.New(xerr.RuleNotActive, "lucky gift rule is not active")
}
// Check 只说明“允许按该 pool 抽奖”,真实价格、区域可用性和扣费仍以 wallet-service 为准。
f.luckyEnabled = true
}
rocketConfig, err := f.s.roomRocketConfig(f.ctx)
if err != nil {
@ -241,6 +275,10 @@ func (f *giftFlow) debitAndSettle(now time.Time) error {
f.settledCommand.GiftTypeCode = f.billing.GetGiftTypeCode()
f.settledCommand.HostPeriodDiamondAdded = f.billing.GetHostPeriodDiamondAdded()
f.settledCommand.HostPeriodCycleKey = f.billing.GetHostPeriodCycleKey()
if err := f.markGiftOperationDebited(now); err != nil {
// wallet 已返回稳定回执但 saga 落盘失败时不能继续抽奖;后台会用相同 command_id 重取钱包回执后推进。
return err
}
if f.cmd.SyntheticLuckyGift {
f.luckyGift = syntheticLuckyGiftResult(f.cmd, f.targetBillings, now)
if f.luckyGift != nil {
@ -261,6 +299,10 @@ func (f *giftFlow) debitAndSettle(now time.Time) error {
f.luckyGift = aggregateLuckyGiftResults(f.cmd.ID(), f.luckyGifts)
}
}
if err := f.markGiftOperationDrawn(now); err != nil {
// 抽奖 owner 已按 command_id 幂等;即使进度写入失败,恢复也只会重放同一结果,不会再次产生奖金。
return err
}
return nil
})
}
@ -488,6 +530,27 @@ func (f *giftFlow) toMutationResult(now time.Time, current *state.RoomState) mut
},
},
}
result.giftV2 = &roomv1.SendGiftResultV2{
ApiVersion: 2,
CommandId: f.cmd.ID(),
ComboSessionId: f.cmd.ComboSessionID,
BatchSeq: f.cmd.BatchSeq,
IdempotentReplay: false,
CommittedRoomVersion: current.Version,
BillingReceiptId: f.billing.GetBillingReceiptId(),
CoinBalanceAfter: finalCoinBalanceAfter,
GiftIncomeBalanceAfter: f.billing.GetGiftIncomeBalanceAfter(),
SettledGiftCount: f.cmd.GiftCount,
SettledTargetUserIds: append([]int64(nil), f.cmd.TargetUserIDs...),
BalanceVersion: f.billing.GetBalanceVersion(),
LuckyGift: f.luckyGift,
LuckyGifts: append([]*roomv1.LuckyGiftDrawResult(nil), f.luckyGifts...),
LuckyHits: luckyHitsFromResults(f.luckyGifts),
BatchDisplay: f.batchDisplay,
RoomHeat: current.Heat,
GiftRank: cloneProtoRank(current.GiftRank),
OperationStatus: GiftOperationStatusCommitted,
}
if f.cmd.RobotGift {
// 机器人房间可以增加房间热度和麦位收礼热度,但不进入跨房礼物榜和房间送礼统计。
result.roomGiftLeaderboard = nil

View File

@ -156,7 +156,7 @@ func luckyGiftResultFromProto(result *luckygiftv1.LuckyGiftDrawResult, targetUse
if result == nil {
return nil
}
return &roomv1.LuckyGiftDrawResult{
mapped := &roomv1.LuckyGiftDrawResult{
Enabled: true,
DrawId: result.GetDrawId(),
CommandId: result.GetCommandId(),
@ -177,6 +177,16 @@ func luckyGiftResultFromProto(result *luckygiftv1.LuckyGiftDrawResult, targetUse
CoinBalanceAfter: 0,
TargetUserId: targetUserID,
}
for _, hit := range result.GetHits() {
if hit == nil || hit.GetRewardCoins() <= 0 {
continue
}
mapped.Hits = append(mapped.Hits, &roomv1.SendGiftLuckyHit{
GiftIndex: hit.GetGiftIndex(), DrawId: hit.GetDrawId(), TargetUserId: targetUserID,
SelectedTierId: hit.GetSelectedTierId(), MultiplierPpm: hit.GetMultiplierPpm(), RewardCoins: hit.GetRewardCoins(),
})
}
return mapped
}
func aggregateLuckyGiftResults(commandID string, results []*roomv1.LuckyGiftDrawResult) *roomv1.LuckyGiftDrawResult {
@ -198,6 +208,7 @@ func aggregateLuckyGiftResults(commandID string, results []*roomv1.LuckyGiftDraw
aggregate.StageFeedback = false
aggregate.HighMultiplier = false
aggregate.CoinBalanceAfter = 0
aggregate.Hits = nil
for _, result := range results {
if result == nil {
continue
@ -210,6 +221,7 @@ func aggregateLuckyGiftResults(commandID string, results []*roomv1.LuckyGiftDraw
aggregate.EffectiveRewardCoins += result.GetEffectiveRewardCoins()
aggregate.StageFeedback = aggregate.StageFeedback || result.GetStageFeedback()
aggregate.HighMultiplier = aggregate.HighMultiplier || result.GetHighMultiplier()
aggregate.Hits = append(aggregate.Hits, result.GetHits()...)
switch result.GetRewardStatus() {
case "pending":
aggregate.RewardStatus = "pending"
@ -233,9 +245,10 @@ func syntheticLuckyGiftResult(cmd command.SendGift, targetBillings []giftTargetB
}
rewardCoins := firstPositiveInt64(cmd.SyntheticLuckyRewardCoins, targetBillings[0].Billing.GetCoinSpent()*5)
multiplier := firstPositiveInt64(cmd.SyntheticLuckyMultiplierPPM, 5000000)
drawID := fmt.Sprintf("robot_lucky_%s_%d", cmd.ID(), now.UnixMilli())
return &roomv1.LuckyGiftDrawResult{
Enabled: true,
DrawId: fmt.Sprintf("robot_lucky_%s_%d", cmd.ID(), now.UnixMilli()),
DrawId: drawID,
CommandId: cmd.ID(),
PoolId: cmd.PoolID,
GiftId: cmd.GiftID,
@ -247,6 +260,9 @@ func syntheticLuckyGiftResult(cmd command.SendGift, targetBillings []giftTargetB
HighMultiplier: multiplier >= 5000000,
CreatedAtMs: now.UnixMilli(),
TargetUserId: cmd.TargetUserID,
Hits: []*roomv1.SendGiftLuckyHit{{
GiftIndex: 1, DrawId: drawID, TargetUserId: cmd.TargetUserID, MultiplierPpm: multiplier, RewardCoins: rewardCoins,
}},
}
}

View File

@ -0,0 +1,347 @@
package service
import (
"bytes"
"context"
"errors"
"fmt"
"log/slog"
"strings"
"sync"
"time"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
"google.golang.org/protobuf/proto"
roomv1 "hyapp.local/api/proto/room/v1"
"hyapp/pkg/appcode"
"hyapp/pkg/xerr"
"hyapp/services/room-service/internal/room/command"
)
const (
giftOperationPersistTimeout = 2 * time.Second
giftOperationRetryDelay = time.Second
// 每条恢复任务从进入 wallet/lucky/Room Cell 到返回都受独立 deadline 约束;不能复用 worker 的长生命周期 ctx。
giftOperationAttemptTimeout = 8 * time.Second
// claim 必须覆盖尝试窗口、Room Cell 已扣费后的受保护提交和失败状态落盘,并保留抖动余量。
giftOperationClaimTTL = 20 * time.Second
)
func (f *giftFlow) loadGiftOperation() error {
operation, exists, err := f.s.repository.GetGiftOperation(f.ctx, f.cmd.RoomID(), f.cmd.ID())
if err != nil || !exists {
return err
}
currentPayload, err := command.IdempotencyPayloadForCommand(f.cmd)
if err != nil {
return err
}
storedPayload, err := command.IdempotencyPayload(operation.CommandPayload)
if err != nil {
return err
}
if !bytes.Equal(currentPayload, storedPayload) {
return xerr.New(xerr.Conflict, "command_id payload conflict")
}
if operation.Status == GiftOperationStatusCommitted {
// committed 与 command log 在同一 MySQL 事务写入;只出现一边说明事实被人工破坏,不能再调用钱包重建。
if _, exists, err := f.s.repository.GetCommand(f.ctx, f.cmd.RoomID(), f.cmd.ID()); err != nil {
return err
} else if !exists {
return xerr.New(xerr.Internal, "committed gift operation is missing command result")
}
return nil
}
if operation.Status != GiftOperationStatusPending && operation.Status != GiftOperationStatusDebited && operation.Status != GiftOperationStatusDrawn {
return nil
}
return f.resumeGiftOperation(operation)
}
func (f *giftFlow) resumeGiftOperation(operation GiftOperation) error {
var storedRequest roomv1.SendGiftRequest
if err := proto.Unmarshal(operation.RequestPayload, &storedRequest); err != nil {
return xerr.New(xerr.Internal, "stored send gift request cannot be decoded")
}
storedCommand, err := command.Deserialize((command.SendGift{}).Type(), operation.CommandPayload)
if err != nil {
return xerr.New(xerr.Internal, "stored send gift command cannot be decoded")
}
giftCommand, ok := storedCommand.(*command.SendGift)
if !ok || giftCommand == nil {
return xerr.New(xerr.Internal, "stored send gift command type is invalid")
}
f.req = &storedRequest
f.cmd = *giftCommand
f.settledCommand = *giftCommand
f.operation = operation
f.operationStarted = true
f.recovering = true
f.debitCompleted = operation.Status == GiftOperationStatusDebited || operation.Status == GiftOperationStatusDrawn
f.drawCompleted = operation.Status == GiftOperationStatusDrawn
return nil
}
func (f *giftFlow) ensureGiftOperation(now time.Time) error {
if f.operationStarted {
return nil
}
commandPayload, err := command.Serialize(f.cmd)
if err != nil {
return err
}
request := proto.Clone(f.req).(*roomv1.SendGiftRequest)
// batch 首次校验可能裁掉已离房目标;恢复只能结算首次确认的实际目标,不能因之后 presence 变化重新扩张或缩小账单。
request.TargetUserIds = append([]int64(nil), f.cmd.TargetUserIDs...)
request.TargetUserId = f.cmd.TargetUserID
request.TargetHostScopes = filterGiftTargetHostScopesProto(request.GetTargetHostScopes(), f.cmd.TargetUserIDs)
request.TargetDisplayProfiles = filterGiftDisplayProfilesProto(request.GetTargetDisplayProfiles(), f.cmd.TargetUserIDs)
requestPayload, err := proto.Marshal(request)
if err != nil {
return xerr.New(xerr.Internal, "send gift recovery request cannot be encoded")
}
operation, created, err := f.s.repository.EnsureGiftOperation(f.ctx, GiftOperation{
AppCode: appcode.FromContext(f.ctx),
RoomID: f.cmd.RoomID(),
CommandID: f.cmd.ID(),
ActorUserID: f.cmd.ActorUserID(),
CommandPayload: commandPayload,
RequestPayload: requestPayload,
Status: GiftOperationStatusPending,
CreatedAtMS: now.UnixMilli(),
UpdatedAtMS: now.UnixMilli(),
})
if err != nil {
return err
}
storedIDPayload, err := command.IdempotencyPayload(operation.CommandPayload)
if err != nil {
return err
}
currentIDPayload, err := command.IdempotencyPayload(commandPayload)
if err != nil {
return err
}
if !bytes.Equal(storedIDPayload, currentIDPayload) {
return xerr.New(xerr.Conflict, "command_id payload conflict")
}
if operation.Status == GiftOperationStatusCompensated {
// Reactivation is deliberately after the normalized payload comparison above. Reversing this
// order would let a conflicting request expose the old stored request to the background worker,
// which could turn an HTTP Conflict into an unrelated wallet debit.
operation, _, err = f.s.repository.ReactivateCompensatedGiftOperation(f.ctx, f.cmd.RoomID(), f.cmd.ID(), now.UnixMilli())
if err != nil {
return err
}
if operation.Status == GiftOperationStatusCompensated {
return xerr.New(xerr.Unavailable, "gift operation reactivation did not advance")
}
}
if !created {
// loadGiftOperation executes before entering the Cell and can legitimately miss a row created by
// an overlapping request. Once Ensure returns that row, all financial calls must use its exact
// stored request/command snapshots. The idempotency comparison intentionally ignores gateway-
// resolved region/host/display fields, but wallet request_hash does not; continuing with this
// caller's refreshed snapshots would turn a valid retry into a ledger conflict.
return f.resumeGiftOperation(operation)
}
f.operation = operation
f.operationStarted = true
return nil
}
func (f *giftFlow) markGiftOperationDebited(now time.Time) error {
f.debitCompleted = true
billingPayload, err := proto.Marshal(f.billing)
if err != nil {
return xerr.New(xerr.Internal, "gift billing result cannot be encoded")
}
if err := f.s.repository.SaveGiftOperationProgress(f.ctx, GiftOperationProgress{
RoomID: f.cmd.RoomID(),
CommandID: f.cmd.ID(),
Status: GiftOperationStatusDebited,
BillingPayload: billingPayload,
UpdatedAtMS: now.UnixMilli(),
}); err != nil {
return err
}
f.operation.Status = GiftOperationStatusDebited
f.operation.BillingPayload = billingPayload
return nil
}
func (f *giftFlow) markGiftOperationDrawn(now time.Time) error {
f.drawCompleted = true
luckyPayload, err := proto.Marshal(&roomv1.SendGiftResultV2{
ApiVersion: 2,
CommandId: f.cmd.ID(),
LuckyGift: f.luckyGift,
LuckyGifts: append([]*roomv1.LuckyGiftDrawResult(nil), f.luckyGifts...),
})
if err != nil {
return xerr.New(xerr.Internal, "gift lucky result cannot be encoded")
}
if err := f.s.repository.SaveGiftOperationProgress(f.ctx, GiftOperationProgress{
RoomID: f.cmd.RoomID(),
CommandID: f.cmd.ID(),
Status: GiftOperationStatusDrawn,
LuckyPayload: luckyPayload,
UpdatedAtMS: now.UnixMilli(),
}); err != nil {
return err
}
f.operation.Status = GiftOperationStatusDrawn
f.operation.LuckyPayload = luckyPayload
return nil
}
func (f *giftFlow) handleGiftOperationFailure(err error) {
if !f.operationStarted || err == nil {
return
}
ctx, cancel := context.WithTimeout(context.WithoutCancel(f.ctx), giftOperationPersistTimeout)
defer cancel()
now := f.s.clock.Now()
if !f.debitCompleted && !giftOperationErrorRetryable(err) {
// wallet 明确拒绝余额不足、配置非法等保证没有扣款pending 可以安全关闭;同载荷的用户主动重试会重新激活。
if persistErr := f.s.repository.MarkGiftOperationCompensated(ctx, f.cmd.RoomID(), f.cmd.ID(), err.Error(), now.UnixMilli()); persistErr != nil {
slog.WarnContext(ctx, "mark gift operation compensated failed", "room_id", f.cmd.RoomID(), "command_id", f.cmd.ID(), "error", persistErr)
}
return
}
nextRetryAtMS := now.Add(giftOperationRetryDelay).UnixMilli()
preserveLease := giftOperationFailureMustKeepClaim(f.recovering, err)
if persistErr := f.s.repository.MarkGiftOperationRetry(ctx, f.cmd.RoomID(), f.cmd.ID(), err.Error(), nextRetryAtMS, now.UnixMilli(), preserveLease); persistErr != nil {
slog.WarnContext(ctx, "mark gift operation retry failed", "room_id", f.cmd.RoomID(), "command_id", f.cmd.ID(), "error", persistErr)
}
}
func giftOperationFailureMustKeepClaim(recovering bool, err error) bool {
if !recovering || err == nil {
return false
}
if errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled) {
return true
}
switch status.Code(err) {
case codes.DeadlineExceeded, codes.Canceled:
return true
default:
return false
}
}
func giftOperationErrorRetryable(err error) bool {
code := xerr.CodeOf(err)
if _, ok := status.FromError(err); ok {
code = xerr.ReasonFromGRPC(err)
}
if code == xerr.Internal || code == xerr.Unavailable {
// unknown/timeout may have reached wallet; retrying the same command_id is the only safe way to discover the stable receipt.
return true
}
return xerr.SpecOf(code).Retryable
}
func (s *Service) ensureNoRecoverableGiftOperations(ctx context.Context, roomID string) error {
open, err := s.repository.HasRecoverableGiftOperations(ctx, roomID)
if err != nil {
return err
}
if open {
// 关闭/删除会让恢复请求无法通过房间生命周期校验,并且硬删除还会抹掉 sagaRoom Cell 串行保证
// 本检查与 SendGift 创建 operation 不会交错,因此明确阻止生命周期终态直到 worker 完成结算。
return xerr.New(xerr.Conflict, "room has recovering gift operations")
}
return nil
}
func filterGiftTargetHostScopesProto(scopes []*roomv1.SendGiftTargetHostScope, targets []int64) []*roomv1.SendGiftTargetHostScope {
allowed := positiveInt64Set(targets)
out := make([]*roomv1.SendGiftTargetHostScope, 0, len(scopes))
for _, scope := range scopes {
if scope != nil && allowed[scope.GetTargetUserId()] {
out = append(out, proto.Clone(scope).(*roomv1.SendGiftTargetHostScope))
}
}
return out
}
func filterGiftDisplayProfilesProto(profiles []*roomv1.SendGiftDisplayProfile, targets []int64) []*roomv1.SendGiftDisplayProfile {
allowed := positiveInt64Set(targets)
out := make([]*roomv1.SendGiftDisplayProfile, 0, len(profiles))
for _, profile := range profiles {
if profile != nil && allowed[profile.GetUserId()] {
out = append(out, proto.Clone(profile).(*roomv1.SendGiftDisplayProfile))
}
}
return out
}
func positiveInt64Set(values []int64) map[int64]bool {
out := make(map[int64]bool, len(values))
for _, value := range values {
if value > 0 {
out[value] = true
}
}
return out
}
// RunGiftOperationRecoveryWorker 持续完成可能已经扣款的 saga它只复用稳定 command_id 调 owner 接口,不自行改房间状态。
func (s *Service) RunGiftOperationRecoveryWorker(ctx context.Context, interval time.Duration) {
if interval <= 0 {
interval = time.Second
}
workerID := fmt.Sprintf("%s-gift-recovery", strings.TrimSpace(s.nodeID))
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
s.recoverGiftOperationsOnce(ctx, workerID)
select {
case <-ctx.Done():
return
case <-ticker.C:
}
}
}
func (s *Service) recoverGiftOperationsOnce(ctx context.Context, workerID string) {
now := s.clock.Now()
operations, err := s.repository.ClaimRecoverableGiftOperations(ctx, workerID, 20, now.UnixMilli(), now.Add(giftOperationClaimTTL).UnixMilli())
if err != nil {
slog.WarnContext(ctx, "claim gift operation recovery failed", "error", err)
return
}
// 被同一批 claim 的任务必须立即开始计时;若继续串行执行,队尾可能尚未进入 Room Cell 就超过租约,
// 被其他实例重复领取。不同房间天然可并行,同房间仍由 Room Cell 串行且等待受各自 deadline 限制。
var wait sync.WaitGroup
for _, operation := range operations {
operation := operation
wait.Add(1)
go func() {
defer wait.Done()
s.recoverGiftOperation(ctx, operation)
}()
}
wait.Wait()
}
func (s *Service) recoverGiftOperation(workerCtx context.Context, operation GiftOperation) {
baseCtx := appcode.WithContext(workerCtx, operation.AppCode)
operationCtx, cancel := context.WithTimeout(baseCtx, giftOperationAttemptTimeout)
defer cancel()
now := s.clock.Now()
var req roomv1.SendGiftRequest
if err := proto.Unmarshal(operation.RequestPayload, &req); err != nil {
_ = s.repository.MarkGiftOperationRetry(operationCtx, operation.RoomID, operation.CommandID, "stored request decode failed", now.Add(time.Minute).UnixMilli(), now.UnixMilli(), false)
return
}
options := giftSendOptions{BatchDisplay: strings.EqualFold(strings.TrimSpace(req.GetDisplayMode()), "batch")}
if _, err := s.sendGiftWithRecovery(operationCtx, &req, options, operation); err != nil {
// sendGiftWithRecovery 用同一 command_id 记录 retrydeadline 后下一轮只发现/重放首次 wallet/lucky 结果,绝不新建账单。
slog.WarnContext(operationCtx, "recover gift operation failed", "room_id", operation.RoomID, "command_id", operation.CommandID, "status", operation.Status, "error", err)
}
}

View File

@ -0,0 +1,145 @@
package service
import (
"context"
"errors"
"testing"
"time"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
"google.golang.org/protobuf/proto"
roomv1 "hyapp.local/api/proto/room/v1"
"hyapp/pkg/appcode"
"hyapp/pkg/xerr"
"hyapp/services/room-service/internal/room/command"
)
type compensatedGiftOperationRepository struct {
Repository
operation GiftOperation
reactivateCalls int
}
func (r *compensatedGiftOperationRepository) EnsureGiftOperation(_ context.Context, _ GiftOperation) (GiftOperation, bool, error) {
return r.operation, false, nil
}
func (r *compensatedGiftOperationRepository) ReactivateCompensatedGiftOperation(_ context.Context, _, _ string, updatedAtMS int64) (GiftOperation, bool, error) {
r.reactivateCalls++
if r.operation.Status == GiftOperationStatusCompensated {
r.operation.Status = GiftOperationStatusPending
r.operation.LastError = ""
r.operation.UpdatedAtMS = updatedAtMS
return r.operation, true, nil
}
return r.operation, false, nil
}
func TestGiftOperationRecoveryAttemptFitsInsideClaimLease(t *testing.T) {
// mutateRoom 在钱包可能已经扣费后允许一个独立的持久提交窗口,失败处理也会脱离过期请求落 retry
// claim 租约必须覆盖三段最坏边界,否则第二实例会在第一实例仍收尾时重复施压。
maxBoundedWork := giftOperationAttemptTimeout + roomMutationCommitTimeout + giftOperationPersistTimeout
if maxBoundedWork >= giftOperationClaimTTL {
t.Fatalf("recovery bounded work %s must be shorter than claim lease %s", maxBoundedWork, giftOperationClaimTTL)
}
}
func TestGiftOperationRecoveryDeadlineKeepsClaimUntilCellCommitBudgetEnds(t *testing.T) {
for _, err := range []error{
context.DeadlineExceeded,
context.Canceled,
status.Error(codes.DeadlineExceeded, "wallet timed out"),
status.Error(codes.Canceled, "attempt canceled"),
} {
if !giftOperationFailureMustKeepClaim(true, err) {
t.Fatalf("recovery timeout must preserve its claim: %v", err)
}
}
if giftOperationFailureMustKeepClaim(false, context.DeadlineExceeded) {
t.Fatal("foreground request has no recovery claim to preserve")
}
if giftOperationFailureMustKeepClaim(true, errors.New("wallet unavailable")) {
t.Fatal("completed retryable failures may release the claim for the next attempt")
}
}
func TestCompensatedGiftOperationReactivatesOnlyAfterPayloadMatch(t *testing.T) {
ctx := appcode.WithContext(context.Background(), appcode.Default)
originalRequest := &roomv1.SendGiftRequest{
Meta: &roomv1.RequestMeta{AppCode: appcode.Default, RoomId: "room-reactivate", CommandId: "cmd-reactivate", ActorUserId: 101},
TargetType: "user", TargetUserId: 202, GiftId: "rose", GiftCount: 1, SenderRegionId: 10,
TargetIsHost: true, TargetHostRegionId: 20, TargetAgencyOwnerUserId: 303,
SenderDisplayProfile: &roomv1.SendGiftDisplayProfile{UserId: 101, Username: "first sender"},
}
// Build the stored command through the same normalizer used by production so this test exercises
// the semantic idempotency comparison instead of depending on hand-written JSON field defaults.
originalFlow := newGiftFlow(&Service{}, ctx, originalRequest, giftSendOptions{})
storedPayload, err := command.Serialize(originalFlow.cmd)
if err != nil {
t.Fatalf("serialize stored gift command: %v", err)
}
storedRequestPayload, err := proto.Marshal(originalRequest)
if err != nil {
t.Fatalf("serialize stored gift request: %v", err)
}
t.Run("conflicting payload stays compensated", func(t *testing.T) {
repository := &compensatedGiftOperationRepository{operation: GiftOperation{
AppCode: appcode.Default, RoomID: "room-reactivate", CommandID: "cmd-reactivate",
CommandPayload: storedPayload, RequestPayload: storedRequestPayload,
Status: GiftOperationStatusCompensated, LastError: "insufficient balance",
}}
service := &Service{repository: repository}
conflictingRequest := &roomv1.SendGiftRequest{
Meta: &roomv1.RequestMeta{AppCode: appcode.Default, RoomId: "room-reactivate", CommandId: "cmd-reactivate", ActorUserId: 101},
TargetType: "user", TargetUserId: 202, GiftId: "different-gift", GiftCount: 1,
}
flow := newGiftFlow(service, ctx, conflictingRequest, giftSendOptions{})
if err := flow.ensureGiftOperation(time.UnixMilli(2_000)); !xerr.IsCode(err, xerr.Conflict) {
t.Fatalf("conflicting command must fail before reactivation: %v", err)
}
if repository.reactivateCalls != 0 || repository.operation.Status != GiftOperationStatusCompensated {
t.Fatalf("conflict exposed stored work to recovery: calls=%d operation=%+v", repository.reactivateCalls, repository.operation)
}
})
t.Run("identical payload CAS reactivates", func(t *testing.T) {
repository := &compensatedGiftOperationRepository{operation: GiftOperation{
AppCode: appcode.Default, RoomID: "room-reactivate", CommandID: "cmd-reactivate",
CommandPayload: storedPayload, RequestPayload: storedRequestPayload,
Status: GiftOperationStatusCompensated, LastError: "insufficient balance",
}}
service := &Service{repository: repository}
flow := newGiftFlow(service, ctx, originalRequest, giftSendOptions{})
if err := flow.ensureGiftOperation(time.UnixMilli(2_000)); err != nil {
t.Fatalf("identical command should reactivate: %v", err)
}
if repository.reactivateCalls != 1 || repository.operation.Status != GiftOperationStatusPending || !flow.operationStarted {
t.Fatalf("identical command did not enter pending exactly once: calls=%d operation=%+v", repository.reactivateCalls, repository.operation)
}
})
t.Run("load miss duplicate resumes first gateway snapshots", func(t *testing.T) {
repository := &compensatedGiftOperationRepository{operation: GiftOperation{
AppCode: appcode.Default, RoomID: "room-reactivate", CommandID: "cmd-reactivate",
CommandPayload: storedPayload, RequestPayload: storedRequestPayload, Status: GiftOperationStatusPending,
}}
service := &Service{repository: repository}
refreshedRequest := proto.Clone(originalRequest).(*roomv1.SendGiftRequest)
refreshedRequest.SenderRegionId = 99
refreshedRequest.TargetHostRegionId = 88
refreshedRequest.TargetAgencyOwnerUserId = 404
refreshedRequest.SenderDisplayProfile = &roomv1.SendGiftDisplayProfile{UserId: 101, Username: "renamed sender"}
flow := newGiftFlow(service, ctx, refreshedRequest, giftSendOptions{})
if err := flow.ensureGiftOperation(time.UnixMilli(2_000)); err != nil {
t.Fatalf("idempotency-equivalent duplicate should resume stored operation: %v", err)
}
if !flow.recovering || flow.cmd.SenderRegionID != 10 || flow.req.GetSenderRegionId() != 10 || flow.cmd.TargetHostRegionID != 20 || flow.req.GetTargetHostRegionId() != 20 || giftDisplayName(flow.cmd.SenderDisplayProfile) != "first sender" {
t.Fatalf("duplicate continued with refreshed gateway snapshots: command=%+v request=%+v", flow.cmd, flow.req)
}
if repository.reactivateCalls != 0 {
t.Fatalf("already-pending duplicate must not reactivate: %d", repository.reactivateCalls)
}
})
}

View File

@ -0,0 +1,83 @@
package service
import (
"time"
"google.golang.org/protobuf/proto"
roomv1 "hyapp.local/api/proto/room/v1"
"hyapp/pkg/xerr"
"hyapp/services/room-service/internal/room/command"
)
func marshalMutationResult(result mutationResult) ([]byte, error) {
if result.giftV2 == nil {
return nil, nil
}
payload, err := proto.Marshal(result.giftV2)
if err != nil {
return nil, xerr.New(xerr.Internal, "send gift v2 result cannot be encoded")
}
return payload, nil
}
func replayMutationResult(record CommandRecord, snapshot *roomv1.RoomSnapshot) (mutationResult, error) {
result := mutationResult{snapshot: snapshot}
if record.CommandType != (command.SendGift{}).Type() || len(record.ResultPayload) == 0 {
// 历史命令没有 typed result继续返回当前 snapshot保持 V1 已上线行为,不伪造账务字段。
return result, nil
}
var stored roomv1.SendGiftResultV2
if err := proto.Unmarshal(record.ResultPayload, &stored); err != nil {
// 已提交结果损坏时必须 fail-close不能重跑 wallet 或幸运礼物来“修复”一个已提交命令。
return mutationResult{}, xerr.New(xerr.Internal, "stored send gift result cannot be decoded")
}
replayed := proto.Clone(&stored).(*roomv1.SendGiftResultV2)
replayed.IdempotentReplay = true
replayed.OperationStatus = GiftOperationStatusCommitted
result.billingReceiptID = replayed.GetBillingReceiptId()
result.coinBalanceAfter = replayed.GetCoinBalanceAfter()
result.giftIncomeBalanceAfter = replayed.GetGiftIncomeBalanceAfter()
result.roomHeat = replayed.GetRoomHeat()
result.giftRank = cloneGiftRankItems(replayed.GetGiftRank())
result.luckyGift = replayed.GetLuckyGift()
result.luckyGifts = append([]*roomv1.LuckyGiftDrawResult(nil), replayed.GetLuckyGifts()...)
result.batchDisplay = replayed.GetBatchDisplay()
result.giftV2 = replayed
return result, nil
}
func cloneGiftRankItems(items []*roomv1.RankItem) []*roomv1.RankItem {
out := make([]*roomv1.RankItem, 0, len(items))
for _, item := range items {
if item != nil {
out = append(out, proto.Clone(item).(*roomv1.RankItem))
}
}
return out
}
func luckyHitsFromResults(results []*roomv1.LuckyGiftDrawResult) []*roomv1.SendGiftLuckyHit {
hits := make([]*roomv1.SendGiftLuckyHit, 0)
for _, result := range results {
if result == nil {
continue
}
for _, hit := range result.GetHits() {
if hit != nil && hit.GetRewardCoins() > 0 {
hits = append(hits, proto.Clone(hit).(*roomv1.SendGiftLuckyHit))
}
}
}
return hits
}
func giftOperationCommitForResult(cmd command.Command, resultPayload []byte, now time.Time) *GiftOperationCommit {
if cmd == nil || cmd.Type() != (command.SendGift{}).Type() || len(resultPayload) == 0 {
return nil
}
return &GiftOperationCommit{
CommandID: cmd.ID(),
ResultPayload: resultPayload,
UpdatedAtMS: now.UnixMilli(),
}
}

View File

@ -0,0 +1,247 @@
package service_test
import (
"context"
"sync/atomic"
"testing"
"time"
luckygiftv1 "hyapp.local/api/proto/luckygift/v1"
roomv1 "hyapp.local/api/proto/room/v1"
walletv1 "hyapp.local/api/proto/wallet/v1"
"hyapp/pkg/appcode"
"hyapp/pkg/xerr"
"hyapp/services/room-service/internal/integration"
roomservice "hyapp/services/room-service/internal/room/service"
"hyapp/services/room-service/internal/router"
"hyapp/services/room-service/internal/testutil/mysqltest"
)
type concurrentV2Wallet struct {
followTestWallet
calls atomic.Int32
started chan struct{}
release chan struct{}
}
func (w *concurrentV2Wallet) DebitGift(ctx context.Context, _ *walletv1.DebitGiftRequest) (*walletv1.DebitGiftResponse, error) {
if w.calls.Add(1) == 1 && w.started != nil {
close(w.started)
}
if w.release != nil {
select {
case <-ctx.Done():
return nil, ctx.Err()
case <-w.release:
}
}
return &walletv1.DebitGiftResponse{
BillingReceiptId: "receipt-v2-stable",
TransactionId: "wallet-tx-v2-stable",
CoinSpent: 100,
ChargeAmount: 100,
HeatValue: 100,
BalanceAfter: 900,
BalanceVersion: 17,
GiftIncomeBalanceAfter: 25,
GiftTypeCode: "normal",
}, nil
}
func TestSendGiftConcurrentCommandReplaysOriginalV2AndKeepsV1Fields(t *testing.T) {
ctx := appcode.WithContext(context.Background(), appcode.Default)
repository := mysqltest.NewRepository(t)
wallet := &concurrentV2Wallet{started: make(chan struct{}), release: make(chan struct{})}
svc := roomservice.New(roomservice.Config{
NodeID: "node-gift-v2-race",
LeaseTTL: 10 * time.Second,
RankLimit: 20,
SnapshotEveryN: 1,
}, router.NewMemoryDirectory(), repository, wallet, integration.NewNoopRoomEventPublisher(), integration.NewNoopOutboxPublisher())
roomID := "room-gift-v2-race"
senderID, targetID := int64(101), int64(202)
createRocketRoom(t, ctx, svc, roomID, senderID, 9001)
joinRocketRoom(t, ctx, svc, roomID, targetID)
req := &roomv1.SendGiftRequest{
Meta: &roomv1.RequestMeta{RequestId: "req-v2-race", CommandId: "combo-v2-race:1", ActorUserId: senderID, RoomId: roomID, AppCode: appcode.Default},
TargetType: "user", TargetUserId: targetID, GiftId: "rose", GiftCount: 5,
ComboSessionId: "combo-v2-race", BatchSeq: 1,
}
responses := make(chan *roomv1.SendGiftResponse, 2)
errs := make(chan error, 2)
go func() {
resp, err := svc.SendGift(ctx, req)
responses <- resp
errs <- err
}()
<-wallet.started
go func() {
resp, err := svc.SendGift(ctx, req)
responses <- resp
errs <- err
}()
close(wallet.release)
var got []*roomv1.SendGiftResponse
for range 2 {
if err := <-errs; err != nil {
t.Fatalf("concurrent SendGift failed: %v", err)
}
got = append(got, <-responses)
}
if calls := wallet.calls.Load(); calls != 1 {
t.Fatalf("same command_id must debit once, got %d wallet calls", calls)
}
replays := 0
for _, resp := range got {
if resp.GetV2() == nil {
t.Fatalf("V2 result is missing: %+v", resp)
}
if resp.GetV2().GetIdempotentReplay() {
replays++
}
if resp.GetV2().GetBillingReceiptId() != "receipt-v2-stable" || resp.GetV2().GetCoinBalanceAfter() != 900 || resp.GetV2().GetBalanceVersion() != 17 || resp.GetV2().GetSettledGiftCount() != 5 || resp.GetV2().GetCommittedRoomVersion() <= 0 {
t.Fatalf("V2 stable result mismatch: %+v", resp.GetV2())
}
// 旧 Flutter 仍读取顶层字段;首次响应和幂等重放都必须回填相同回执、余额和热度。
if resp.GetBillingReceiptId() != resp.GetV2().GetBillingReceiptId() || resp.GetCoinBalanceAfter() != resp.GetV2().GetCoinBalanceAfter() || resp.GetRoomHeat() != resp.GetV2().GetRoomHeat() {
t.Fatalf("V1 compatibility fields drifted: response=%+v v2=%+v", resp, resp.GetV2())
}
}
if replays != 1 {
t.Fatalf("exactly one overlapping request should be replayed, got %d", replays)
}
if heat := got[0].GetRoom().GetHeat(); heat != 100 {
t.Fatalf("same command_id changed room heat more than once: %d", heat)
}
}
type recoverableLuckyClient struct {
calls atomic.Int32
}
func (*recoverableLuckyClient) CheckLuckyGift(context.Context, *luckygiftv1.CheckLuckyGiftRequest) (*luckygiftv1.CheckLuckyGiftResponse, error) {
return &luckygiftv1.CheckLuckyGiftResponse{Enabled: true, RuleVersion: 1, PoolId: "lucky-pool"}, nil
}
func (c *recoverableLuckyClient) ExecuteLuckyGiftDraw(_ context.Context, req *luckygiftv1.ExecuteLuckyGiftDrawRequest) (*luckygiftv1.ExecuteLuckyGiftDrawResponse, error) {
if c.calls.Add(1) == 1 {
return nil, xerr.New(xerr.Unavailable, "temporary lucky outage")
}
meta := req.GetLuckyGift()
return &luckygiftv1.ExecuteLuckyGiftDrawResponse{Result: &luckygiftv1.LuckyGiftDrawResult{
DrawId: "draw-recovered", CommandId: meta.GetCommandId(), GiftId: meta.GetGiftId(), PoolId: meta.GetPoolId(),
MultiplierPpm: 2_000_000, EffectiveRewardCoins: 200, RewardStatus: "granted", CoinBalanceAfter: 1100,
Hits: []*luckygiftv1.LuckyGiftHit{{GiftIndex: 1, DrawId: "draw-recovered", SelectedTierId: "2x", MultiplierPpm: 2_000_000, RewardCoins: 200}},
}}, nil
}
func (c *recoverableLuckyClient) BatchExecuteLuckyGiftDraw(ctx context.Context, req *luckygiftv1.BatchExecuteLuckyGiftDrawRequest) (*luckygiftv1.BatchExecuteLuckyGiftDrawResponse, error) {
results := make([]*luckygiftv1.LuckyGiftDrawResult, 0, len(req.GetLuckyGifts()))
for _, meta := range req.GetLuckyGifts() {
resp, err := c.ExecuteLuckyGiftDraw(ctx, &luckygiftv1.ExecuteLuckyGiftDrawRequest{LuckyGift: meta})
if err != nil {
return nil, err
}
results = append(results, resp.GetResult())
}
return &luckygiftv1.BatchExecuteLuckyGiftDrawResponse{Results: results}, nil
}
func TestGiftOperationWorkerFinishesDebitedRequestWithoutClientRetry(t *testing.T) {
ctx := appcode.WithContext(context.Background(), appcode.Default)
repository := mysqltest.NewRepository(t)
wallet := &concurrentV2Wallet{}
lucky := &recoverableLuckyClient{}
svc := roomservice.New(roomservice.Config{
NodeID: "node-gift-v2-recovery",
LeaseTTL: 10 * time.Second,
RankLimit: 20,
SnapshotEveryN: 1,
}, router.NewMemoryDirectory(), repository, wallet, integration.NewNoopRoomEventPublisher(), integration.NewNoopOutboxPublisher(), lucky)
roomID := "room-gift-v2-recovery"
senderID, targetID := int64(301), int64(302)
createRocketRoom(t, ctx, svc, roomID, senderID, 9001)
joinRocketRoom(t, ctx, svc, roomID, targetID)
req := &roomv1.SendGiftRequest{
Meta: &roomv1.RequestMeta{RequestId: "req-v2-recovery", CommandId: "cmd-v2-recovery", ActorUserId: senderID, RoomId: roomID, AppCode: appcode.Default},
TargetType: "user", TargetUserId: targetID, GiftId: "lucky-rose", GiftCount: 1, PoolId: "lucky-pool",
}
if _, err := svc.SendGift(ctx, req); !xerr.IsCode(err, xerr.Unavailable) {
t.Fatalf("first draw should fail after debit, got %v", err)
}
operation, exists, err := repository.GetGiftOperation(ctx, roomID, req.GetMeta().GetCommandId())
if err != nil || !exists || operation.Status != roomservice.GiftOperationStatusDebited {
t.Fatalf("debited saga was not persisted: exists=%v operation=%+v err=%v", exists, operation, err)
}
if _, err := svc.CloseRoom(ctx, &roomv1.CloseRoomRequest{
Meta: &roomv1.RequestMeta{CommandId: "close-during-gift-recovery", ActorUserId: senderID, RoomId: roomID, AppCode: appcode.Default},
}); !xerr.IsCode(err, xerr.Conflict) {
t.Fatalf("room close must wait for debited gift recovery, got %v", err)
}
workerCtx, cancel := context.WithCancel(ctx)
defer cancel()
go svc.RunGiftOperationRecoveryWorker(workerCtx, 10*time.Millisecond)
deadline := time.Now().Add(3 * time.Second)
for {
record, committed, err := repository.GetCommand(ctx, roomID, req.GetMeta().GetCommandId())
if err != nil {
t.Fatalf("read recovered command: %v", err)
}
if committed {
if len(record.ResultPayload) == 0 {
t.Fatal("recovered command did not persist V2 result")
}
break
}
if time.Now().After(deadline) {
t.Fatal("server recovery worker did not commit debited gift")
}
time.Sleep(20 * time.Millisecond)
}
replayed, err := svc.SendGift(ctx, req)
if err != nil {
t.Fatalf("replay recovered gift failed: %v", err)
}
// HTTP 送礼回执只返回 wallet debit 的稳定账后余额;幸运返奖由异步钱包/IM 事实随后推进,
// 不能把 lucky-service 的临时 coin_balance_after 冒充同一 sender 版本的扣费余额。
if !replayed.GetV2().GetIdempotentReplay() || replayed.GetLuckyGift().GetDrawId() != "draw-recovered" || replayed.GetCoinBalanceAfter() != 900 || len(replayed.GetV2().GetLuckyHits()) != 1 || replayed.GetV2().GetLuckyHits()[0].GetGiftIndex() != 1 {
t.Fatalf("recovered original result was not replayed: %+v", replayed)
}
if _, err := svc.CloseRoom(ctx, &roomv1.CloseRoomRequest{
Meta: &roomv1.RequestMeta{CommandId: "close-after-gift-recovery", ActorUserId: senderID, RoomId: roomID, AppCode: appcode.Default},
}); err != nil {
t.Fatalf("room close should proceed after gift saga commits: %v", err)
}
}
func TestSendGiftOwnerRejectsExcessiveUnitsBeforeWallet(t *testing.T) {
ctx := appcode.WithContext(context.Background(), appcode.Default)
repository := mysqltest.NewRepository(t)
wallet := &concurrentV2Wallet{}
svc := roomservice.New(roomservice.Config{NodeID: "node-gift-v2-limit", LeaseTTL: 10 * time.Second, SnapshotEveryN: 1}, router.NewMemoryDirectory(), repository, wallet, integration.NewNoopRoomEventPublisher(), integration.NewNoopOutboxPublisher())
roomID := "room-gift-v2-limit"
createRocketRoom(t, ctx, svc, roomID, 401, 9001)
targets := []int64{402, 403, 404, 405, 406, 407}
for _, targetID := range targets {
joinRocketRoom(t, ctx, svc, roomID, targetID)
}
_, err := svc.SendGift(ctx, &roomv1.SendGiftRequest{
Meta: &roomv1.RequestMeta{CommandId: "cmd-v2-limit", ActorUserId: 401, RoomId: roomID, AppCode: appcode.Default},
TargetType: "user", TargetUserIds: targets, GiftId: "rose", GiftCount: 999,
})
if !xerr.IsCode(err, xerr.InvalidArgument) {
t.Fatalf("owner total unit limit should reject request, got %v", err)
}
if wallet.calls.Load() != 0 {
t.Fatalf("rejected request reached wallet %d times", wallet.calls.Load())
}
}
var _ integration.WalletClient = (*concurrentV2Wallet)(nil)
var _ integration.LuckyGiftClient = (*recoverableLuckyClient)(nil)

View File

@ -90,6 +90,9 @@ func (s *Service) CloseRoom(ctx context.Context, req *roomv1.CloseRoomRequest) (
return mutationResult{}, nil, err
}
}
if err := s.ensureNoRecoverableGiftOperations(ctx, current.RoomID); err != nil {
return mutationResult{}, nil, err
}
kickTargets := closeRoomKickTargets(current)
if len(kickTargets) > 0 {

View File

@ -199,20 +199,28 @@ func (s *Service) processPendingOutbox(ctx context.Context, options OutboxWorker
return fmt.Errorf("unsupported outbox retry_strategy %q", options.RetryStrategy)
}
// 补偿 worker 先抢占 pending/retryable 事件,再投递,避免多机同时处理同一批。
records, err := s.claimPendingOutbox(ctx, options)
if err != nil {
return err
}
sortOutboxRecords(records)
for _, record := range records {
for processed := 0; processed < options.BatchSize; processed++ {
select {
case <-ctx.Done():
// 退出时不再领取新事件;已抢占但未处理完的事件会在 lock_until_ms 到期后重新可见。
// 退出时不再领取新事件;已领取的单条事实会在有界 lease 后重新可见。
return ctx.Err()
default:
}
// RocketMQ/direct publisher 都是同步调用,因此每次只 claim 一条。旧实现一次
// claim 整批却只给一个 publish_timeout 的 lease队尾尚未开始发送就可能被
// 其他实例接管并发重复发布。逐条 claim 同时避免把崩溃恢复时间放大为
// batch_size*publish_timeout吞吐由配置的 worker concurrency 横向提供。
claimOptions := options
claimOptions.BatchSize = 1
records, err := s.claimPendingOutbox(ctx, claimOptions)
if err != nil {
return err
}
if len(records) == 0 {
return nil
}
record := records[0]
if record.RetryCount >= options.MaxRetryCount {
// 已达到最大失败次数的历史事件直接转死信,防止每轮 worker 继续打外部系统。
markCtx, markCancel := context.WithTimeout(appcode.WithContext(context.Background(), record.AppCode), options.PublishTimeout)
@ -235,7 +243,7 @@ func (s *Service) processPendingOutbox(ctx context.Context, options OutboxWorker
publishCtx, cancel := context.WithTimeout(appcode.WithContext(context.Background(), record.AppCode), options.PublishTimeout)
// 发布上下文不继承 worker ctx避免 shutdown 取消导致已开始的单条外部投递无界处于未知状态。
err := s.publishOutboxRecord(publishCtx, record)
err = s.publishOutboxRecord(publishCtx, record)
cancel()
if err != nil {
// 投递失败转为 retryable并写入指数退避时间等待下一轮 worker 到期后再抢占。
@ -282,7 +290,8 @@ func (s *Service) processPendingOutbox(ctx context.Context, options OutboxWorker
}
func sortOutboxRecords(records []outbox.Record) {
// DB claim 负责并发安全;批内稳定排序只把礼物、上下麦和进退房展示事件排到热度/榜单前面,降低发送方可感知尾延迟。
// Direct IM 和机器人展示仍会在内存批次内调用此排序;主 MQ worker 改为
// 逐条 claim 后不依赖批内排序,但保留统一优先级可避免展示路径语义漂移。
sort.SliceStable(records, func(i, j int) bool {
leftPriority := outboxEventPriority(records[i].EventType)
rightPriority := outboxEventPriority(records[j].EventType)
@ -308,11 +317,21 @@ func outboxEventPriority(eventType string) int {
}
func (s *Service) claimPendingOutbox(ctx context.Context, options OutboxWorkerOptions) ([]outbox.Record, error) {
lockUntilMS := time.Now().UTC().Add(options.PublishTimeout).UnixMilli()
lockUntilMS := time.Now().UTC().Add(outboxSingleRecordLease(options.PublishTimeout)).UnixMilli()
workerID := firstNonEmpty(strings.TrimSpace(options.WorkerID), s.nodeID)
return s.repository.ClaimPendingOutbox(ctx, workerID, options.BatchSize, lockUntilMS)
}
func outboxSingleRecordLease(publishTimeout time.Duration) time.Duration {
if publishTimeout <= 0 {
publishTimeout = defaultOutboxWorkerOptions().PublishTimeout
}
// 一条事实最多经历一次外部发布 deadline 和一次状态更新 deadline额外
// 预留一个 deadline 吸收调度延迟。at-least-once 的 publish 成功/ACK 失败
// 窗口仍由下游 event_id 幂等收敛,但不会因批内队尾 lease 过期主动放大重复。
return 3 * publishTimeout
}
func (s *Service) markOutboxDelivered(ctx context.Context, eventID string) error {
return s.repository.MarkOutboxDelivered(ctx, eventID)
}

View File

@ -2,19 +2,28 @@ package service
import (
"testing"
"time"
"hyapp/services/room-service/internal/room/outbox"
)
func TestOutboxSingleRecordLeaseCoversPublishAndStateWrite(t *testing.T) {
if got := outboxSingleRecordLease(15 * time.Second); got != 45*time.Second {
t.Fatalf("single-record lease = %s, want 45s", got)
}
if got := outboxSingleRecordLease(0); got != 9*time.Second {
t.Fatalf("default single-record lease = %s, want 9s", got)
}
}
func TestSortOutboxRecordsPrioritizesGiftDisplay(t *testing.T) {
// 低优先级事件故意给更早 created_at_ms验证 worker 不会让热度/榜单压住礼物展示。
// Direct IM/robot display 仍使用批内排序;低优先级事件故意更早,验证礼物展示优先
records := []outbox.Record{
{EventID: "rank", EventType: "RoomRankChanged", CreatedAtMS: 1},
{EventID: "gift", EventType: "RoomGiftSent", CreatedAtMS: 2},
{EventID: "other", EventType: "RoomNoticeCreated", CreatedAtMS: 0},
{EventID: "heat", EventType: "RoomHeatChanged", CreatedAtMS: 0},
}
sortOutboxRecords(records)
got := []string{records[0].EventID, records[1].EventID, records[2].EventID, records[3].EventID}

View File

@ -3,10 +3,12 @@ package service
import (
"bytes"
"context"
"errors"
"fmt"
"time"
"hyapp/pkg/appcode"
"hyapp/pkg/giftlimits"
"hyapp/pkg/xerr"
"hyapp/services/room-service/internal/room/command"
"hyapp/services/room-service/internal/room/outbox"
@ -15,7 +17,10 @@ import (
"hyapp/services/room-service/internal/router"
)
const roomMutationCommitTimeout = 5 * time.Second
const (
roomMutationCommitTimeout = 5 * time.Second
roomMutationConfirmCommitTimeout = 2 * time.Second
)
func (s *Service) mutateRoom(ctx context.Context, cmd command.Command, replayable bool, mutate func(now time.Time, current *state.RoomState, localRank *rank.LocalRank) (mutationResult, []outbox.Record, error)) (mutationResult, error) {
startedAt := time.Now()
@ -28,25 +33,31 @@ func (s *Service) mutateRoom(ctx context.Context, cmd command.Command, replayabl
// command meta 是幂等、路由、审计和权限判断的共同基础,缺一不可。
return mutationResult{}, xerr.New(xerr.InvalidArgument, "command meta is incomplete")
}
if !giftlimits.ValidCommandID(cmd.ID()) {
// room_command_log.command_id 是 VARCHAR(128);入口显式拒绝比依赖 MySQL 截断/报错更安全,
// 同时确保 wallet、lucky-gift 和 Room Cell 使用完全相同的稳定幂等键。
return mutationResult{}, xerr.New(xerr.InvalidArgument, "command_id is too long")
}
if s.isDraining() {
// 下线中的节点只让已经进入执行链路的命令收尾,不再接收新的 Room Cell 命令。
return mutationResult{}, xerr.New(xerr.Unavailable, "room service is draining")
}
idempotencyStartedAt := time.Now()
payload, seen, err := s.commandPayloadForIdempotency(ctx, cmd)
payload, existingCommand, seen, err := s.commandRecordForIdempotency(ctx, cmd)
idempotencyMS = elapsedMS(idempotencyStartedAt)
if err != nil {
return mutationResult{}, err
}
if seen {
// 幂等命令不重新执行 mutate也不会再次调用 wallet 或写 outbox。
// 幂等命令不重新执行 mutate也不会再次调用 wallet 或写 outboxSendGift V2 从首次 result_payload
// 重放账务和幸运礼物结果,旧命令没有结果时才维持历史 snapshot-only 兼容语义。
snapshot, err := s.currentSnapshot(ctx, cmd.RoomID())
if err != nil {
return mutationResult{}, err
}
return mutationResult{snapshot: snapshot}, nil
return replayMutationResult(existingCommand, snapshot)
}
// 确保当前节点持有房间 lease并在没有内存 Cell 时完成恢复。
@ -58,6 +69,15 @@ func (s *Service) mutateRoom(ctx context.Context, cmd command.Command, replayabl
}
value, err := roomCell.Do(ctx, func(current *state.RoomState, currentRank *rank.LocalRank) (any, error) {
// 外层预检与进入 Room Cell 之间可能已有相同 command_id 完成提交;必须在串行临界区内权威重查,
// 否则第二个请求会复用 wallet 幂等回执却再次增加房间热度、榜单和 outbox。
_, committedCommand, alreadyCommitted, err := s.commandRecordForIdempotency(ctx, cmd)
if err != nil {
return nil, err
}
if alreadyCommitted {
return replayMutationResult(committedCommand, snapshotWithApp(ctx, current.ToProto()))
}
// 在副本上执行命令,只有 command log/outbox 保存成功后才整体替换当前状态。
nextState := current.Clone()
nextRank := currentRank.Clone()
@ -94,19 +114,26 @@ func (s *Service) mutateRoom(ctx context.Context, cmd command.Command, replayabl
directIMRecords = append(directIMRecords, result.directIMRecords...)
saveStartedAt := time.Now()
commitCtx, commitCancel := roomMutationCommitContext(ctx)
err := s.repository.SaveMutation(commitCtx, MutationCommit{
Command: CommandRecord{
AppCode: appcode.FromContext(ctx),
RoomID: cmd.RoomID(),
RoomVersion: nextState.Version,
CommandID: cmd.ID(),
CommandType: cmd.Type(),
Payload: commandPayloadForRecord(payload, result),
Replayable: replayable,
OwnerNodeID: s.nodeID,
LeaseToken: lease.LeaseToken,
CreatedAtMS: now.UnixMilli(),
},
resultPayload, err := marshalMutationResult(result)
if err != nil {
commitCancel()
return nil, err
}
commandRecord := CommandRecord{
AppCode: appcode.FromContext(ctx),
RoomID: cmd.RoomID(),
RoomVersion: nextState.Version,
CommandID: cmd.ID(),
CommandType: cmd.Type(),
Payload: commandPayloadForRecord(payload, result),
ResultPayload: resultPayload,
Replayable: replayable,
OwnerNodeID: s.nodeID,
LeaseToken: lease.LeaseToken,
CreatedAtMS: now.UnixMilli(),
}
err = s.repository.SaveMutation(commitCtx, MutationCommit{
Command: commandRecord,
OutboxRecords: durableOutboxRecords,
DeleteRoom: result.deleteRoom,
RoomStatus: result.roomStatus,
@ -118,10 +145,29 @@ func (s *Service) mutateRoom(ctx context.Context, cmd command.Command, replayabl
RoomUserGiftStats: result.roomUserGiftStats,
RoomUserContributionPeriodStats: result.roomUserContributionPeriodStats,
RoomWeeklyContribution: result.roomWeeklyContribution,
GiftOperation: giftOperationCommitForResult(cmd, resultPayload, now),
})
commitCancel()
if errors.Is(err, ErrCommandAlreadyCommitted) {
// MySQL 唯一键是跨进程最后防线。事务已经整体回滚,此分支只读取首次结果,绝不能替换当前 Cell 副本。
committed, exists, loadErr := s.loadCommandAfterMutationError(ctx, cmd.RoomID(), cmd.ID())
if loadErr != nil {
return nil, loadErr
}
if !exists {
return nil, err
}
return replayMutationResult(committed, snapshotWithApp(ctx, current.ToProto()))
}
if err != nil {
return nil, err
// COMMIT can succeed in MySQL while its acknowledgement is lost to a connection reset. The gift
// operation is then already committed and recovery workers correctly stop claiming it, so returning
// the transport error without advancing this Cell would leave memory permanently behind its owner DB.
// Only the exact record produced above, including owner lease, room version and typed result, is
// sufficient proof; all earlier SQL failures still return the original error unchanged.
if !s.mutationCommitConfirmed(ctx, commandRecord) {
return nil, err
}
}
saveMutationMS += elapsedMS(saveStartedAt)
// 保存已提交 outbox 记录,出 Cell 锁后由 outbox worker 异步投递 activity/MQ 业务事实,避免外部慢调用占住房间串行执行队列。
@ -148,22 +194,21 @@ func (s *Service) mutateRoom(ctx context.Context, cmd command.Command, replayabl
}
saveStartedAt := time.Now()
commitCtx, commitCancel := roomMutationCommitContext(ctx)
err := s.repository.SaveMutation(commitCtx, MutationCommit{
Command: CommandRecord{
AppCode: appcode.FromContext(ctx),
RoomID: cmd.RoomID(),
RoomVersion: current.Version,
CommandID: cmd.ID(),
CommandType: cmd.Type(),
Payload: payload,
Replayable: false,
OwnerNodeID: s.nodeID,
LeaseToken: lease.LeaseToken,
CreatedAtMS: now.UnixMilli(),
},
})
commandRecord := CommandRecord{
AppCode: appcode.FromContext(ctx),
RoomID: cmd.RoomID(),
RoomVersion: current.Version,
CommandID: cmd.ID(),
CommandType: cmd.Type(),
Payload: payload,
Replayable: false,
OwnerNodeID: s.nodeID,
LeaseToken: lease.LeaseToken,
CreatedAtMS: now.UnixMilli(),
}
err := s.repository.SaveMutation(commitCtx, MutationCommit{Command: commandRecord})
commitCancel()
if err != nil {
if err != nil && !s.mutationCommitConfirmed(ctx, commandRecord) {
return nil, err
}
saveMutationMS += elapsedMS(saveStartedAt)
@ -211,46 +256,81 @@ func roomMutationCommitContext(ctx context.Context) (context.Context, context.Ca
return context.WithTimeout(context.WithoutCancel(ctx), roomMutationCommitTimeout)
}
func (s *Service) loadCommandAfterMutationError(ctx context.Context, roomID string, commandID string) (CommandRecord, bool, error) {
// A caller disconnect or the expired recovery-attempt budget must not cancel the one authoritative
// read that resolves an unknown MySQL COMMIT result. The short independent deadline prevents this
// safety check from becoming another way to hold the Room Cell indefinitely.
confirmCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), roomMutationConfirmCommitTimeout)
defer cancel()
return s.repository.GetCommand(confirmCtx, roomID, commandID)
}
func (s *Service) mutationCommitConfirmed(ctx context.Context, expected CommandRecord) bool {
committed, exists, err := s.loadCommandAfterMutationError(ctx, expected.RoomID, expected.CommandID)
if err != nil || !exists {
return false
}
// Exact byte equality is intentional: SendGift replaces its command payload with the settled wallet
// facts and stores a protobuf typed result. Idempotency-equivalent input alone cannot prove that this
// particular nextState/outbox transaction committed under the lease currently owned by the Cell.
return committed.AppCode == expected.AppCode &&
committed.RoomID == expected.RoomID &&
committed.RoomVersion == expected.RoomVersion &&
committed.CommandID == expected.CommandID &&
committed.CommandType == expected.CommandType &&
committed.OwnerNodeID == expected.OwnerNodeID &&
committed.LeaseToken == expected.LeaseToken &&
committed.Replayable == expected.Replayable &&
committed.CreatedAtMS == expected.CreatedAtMS &&
bytes.Equal(committed.Payload, expected.Payload) &&
bytes.Equal(committed.ResultPayload, expected.ResultPayload)
}
func (s *Service) commandPayloadForIdempotency(ctx context.Context, cmd command.Command) ([]byte, bool, error) {
payload, _, seen, err := s.commandRecordForIdempotency(ctx, cmd)
return payload, seen, err
}
func (s *Service) commandRecordForIdempotency(ctx context.Context, cmd command.Command) ([]byte, CommandRecord, bool, error) {
// 先序列化当前命令,后续无论首次提交还是冲突校验都使用同一份 payload。
payload, err := command.Serialize(cmd)
if err != nil {
return nil, false, err
return nil, CommandRecord{}, false, err
}
// command_id 在同一 room_id 内唯一;已有记录表示客户端重试或 command_id 被错误复用。
record, exists, err := s.repository.GetCommand(ctx, cmd.RoomID(), cmd.ID())
if err != nil {
return nil, false, err
return nil, CommandRecord{}, false, err
}
if !exists {
return payload, false, nil
return payload, CommandRecord{}, false, nil
}
if cmd.Type() == (command.SetRoomPassword{}).Type() && record.CommandType == (command.SetRoomPassword{}).Type() {
// 密码命令的持久 payload 只有哈希,必须走专门逻辑比较 transient 明文和已存哈希。
matched, err := setRoomPasswordIdempotencyMatches(cmd, payload, record.Payload)
if err != nil {
return nil, true, err
return nil, record, true, err
}
if !matched {
return nil, true, xerr.New(xerr.Conflict, "command_id payload conflict")
return nil, record, true, xerr.New(xerr.Conflict, "command_id payload conflict")
}
return payload, true, nil
return payload, record, true, nil
}
currentIDPayload, err := command.IdempotencyPayload(payload)
if err != nil {
return nil, true, err
return nil, record, true, err
}
storedIDPayload, err := command.IdempotencyPayload(record.Payload)
if err != nil {
return nil, true, err
return nil, record, true, err
}
if record.CommandType != cmd.Type() || !bytes.Equal(storedIDPayload, currentIDPayload) {
// 同一个 command_id 携带不同业务载荷必须显式冲突,不能返回旧命令结果掩盖客户端错误。
return nil, true, xerr.New(xerr.Conflict, "command_id payload conflict")
return nil, record, true, xerr.New(xerr.Conflict, "command_id payload conflict")
}
return payload, true, nil
return payload, record, true, nil
}
func setRoomPasswordIdempotencyMatches(cmd command.Command, currentPayload []byte, storedPayload []byte) (bool, error) {

Some files were not shown because too many files have changed in this diff Show More