礼物性能优化
This commit is contained in:
parent
f19f212e54
commit
7d011ce3d9
@ -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,
|
||||
},
|
||||
|
||||
@ -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
@ -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
@ -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
@ -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 是一笔批量送礼中的单个接收方账务快照。
|
||||
|
||||
@ -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 Tag:consumer 先部署
|
||||
`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`。
|
||||
|
||||
## 修复掉的问题
|
||||
|
||||
|
||||
@ -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 永久刷日志 |
|
||||
|
||||
|
||||
94
docs/送礼连击V2协议.md
Normal file
94
docs/送礼连击V2协议.md
Normal 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
32
pkg/giftlimits/limits.go
Normal 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
|
||||
}
|
||||
29
pkg/giftlimits/limits_test.go
Normal file
29
pkg/giftlimits/limits_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
@ -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_type,consumer 复用这些常量拼 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 构造无损迁移 selector:legacy 分支消费切换前
|
||||
// 已存在 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"`
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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),
|
||||
|
||||
224
server/admin/internal/modules/appconfig/gift_combo.go
Normal file
224
server/admin/internal/modules/appconfig/gift_combo.go
Normal 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 行为。管理员下一次
|
||||
// 保存会写回 2;Update 入口仍通过严格校验拒绝新建 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)
|
||||
}
|
||||
60
server/admin/internal/modules/appconfig/gift_combo_test.go
Normal file
60
server/admin/internal/modules/appconfig/gift_combo_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
|
||||
@ -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"),
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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 {
|
||||
|
||||
194
server/admin/internal/modules/dashboard/user_profile.go
Normal file
194
server/admin/internal/modules/dashboard/user_profile.go
Normal 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
|
||||
}
|
||||
137
server/admin/internal/modules/dashboard/user_profile_test.go
Normal file
137
server/admin/internal/modules/dashboard/user_profile_test.go
Normal 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_audit;statistics-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
|
||||
}
|
||||
556
server/admin/internal/modules/databi/finance_recharge.go
Normal file
556
server/admin/internal/modules/databi/finance_recharge.go
Normal 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
|
||||
}
|
||||
217
server/admin/internal/modules/databi/finance_recharge_test.go
Normal file
217
server/admin/internal/modules/databi/finance_recharge_test.go
Normal 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
|
||||
}
|
||||
@ -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 统计数据失败")
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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"`
|
||||
|
||||
305
server/admin/internal/modules/financeorder/exchange_rate.go
Normal file
305
server/admin/internal/modules/financeorder/exchange_rate.go
Normal 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 "e, 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)
|
||||
}
|
||||
@ -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 {
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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-service,Aslan/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,
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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 == "" {
|
||||
|
||||
@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
179
services/gateway-service/internal/appconfig/gift_combo.go
Normal file
179
services/gateway-service/internal/appconfig/gift_combo.go
Normal 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 = ©
|
||||
continue
|
||||
}
|
||||
copy := item
|
||||
baseline = ©
|
||||
}
|
||||
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)
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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"}
|
||||
|
||||
@ -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{
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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 立即释放用户 lease;deadline 已耗尽时释放可能失败,
|
||||
// 但 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)
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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]
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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() {
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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 使用与客户端断连解耦的短 context;Redis 暂时失败时租约 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)
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
|
||||
@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -23,18 +23,24 @@ import (
|
||||
// 路径注册集中在 httproutes,gateway 依赖装配和鉴权 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,
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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 outbox,worker 后续负责 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()
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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{
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -74,6 +74,8 @@ rocketmq:
|
||||
namespace: "hyapp-prod"
|
||||
send_timeout: "3s"
|
||||
retry: 2
|
||||
# Phase A 必须保持 legacy;Phase B 经消费者订阅和存量积压验证后,仅将该值切为 event_type。
|
||||
room_event_tag_mode: "legacy"
|
||||
room_outbox:
|
||||
enabled: true
|
||||
topic: "hyapp_room_outbox"
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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';
|
||||
@ -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) {
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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")
|
||||
}
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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},
|
||||
}
|
||||
}
|
||||
@ -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")
|
||||
|
||||
@ -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")
|
||||
}
|
||||
}
|
||||
@ -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{
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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,
|
||||
}},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
347
services/room-service/internal/room/service/gift_operation.go
Normal file
347
services/room-service/internal/room/service/gift_operation.go
Normal 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 {
|
||||
// 关闭/删除会让恢复请求无法通过房间生命周期校验,并且硬删除还会抹掉 saga;Room 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 记录 retry;deadline 后下一轮只发现/重放首次 wallet/lucky 结果,绝不新建账单。
|
||||
slog.WarnContext(operationCtx, "recover gift operation failed", "room_id", operation.RoomID, "command_id", operation.CommandID, "status", operation.Status, "error", err)
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@ -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(),
|
||||
}
|
||||
}
|
||||
247
services/room-service/internal/room/service/gift_v2_test.go
Normal file
247
services/room-service/internal/room/service/gift_v2_test.go
Normal 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)
|
||||
@ -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 {
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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 或写 outbox;SendGift 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
Loading…
x
Reference in New Issue
Block a user