礼物性能优化

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

File diff suppressed because it is too large Load Diff

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -45,6 +45,7 @@ func (h *Handler) Overview(c *gin.Context) {
EndMS: queryInt64(c, "end_ms"),
AppCodes: splitCSV(c.Query("app_codes")),
RegionID: queryInt64(c, "region_id"),
RegionIDs: queryInt64CSV(c.Query("region_ids")),
})
if err != nil {
response.ServerError(c, "获取 BI 统计数据失败")

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,7 +1,11 @@
package payment
import (
"context"
"fmt"
"sort"
"strings"
"sync"
"hyapp-admin-server/internal/appctx"
"hyapp-admin-server/internal/middleware"
@ -55,6 +59,44 @@ type rechargeBillOverviewDTO struct {
Withdrawal rechargeBillWithdrawalStatsDTO `json:"withdrawal"`
}
// FinanceRechargeOverviewQuery 是供其他后台模块复用财务充值口径的只读查询。
// ScopeRegionIDs 非空时只汇总这些区域,避免数据范围受限的 BI 查询先读全量再裁剪。
type FinanceRechargeOverviewQuery struct {
AppCode string
StartAtMS int64
EndAtMS int64
ScopeRegionIDs []int64
IncludeRegions bool
TZOffsetMinutes int32
}
// FinanceRechargeDailyBucket 与 Finance 页面趋势使用同一组渠道字段和切日口径。
type FinanceRechargeDailyBucket struct {
Date string
GoogleUSDMinor int64
ThirdPartyUSDMinor int64
CoinSellerUSDMinor int64
}
// FinanceRechargeRegionOverview 在全量查询时只返回 Finance 页面已有的区域总额;
// 仅显式区域范围查询才补齐逐日渠道,避免默认概览对所有区域做 N+1 聚合。
type FinanceRechargeRegionOverview struct {
RegionID int64
TotalUSDMinor int64
DailyAvailable bool
Daily []FinanceRechargeDailyBucket
}
type FinanceRechargeOverview struct {
Daily []FinanceRechargeDailyBucket
Regions []FinanceRechargeRegionOverview
}
// FinanceRechargeOverviewSource 让 Social BI 依赖 Finance 的业务口径,而不是依赖 payment HTTP handler。
type FinanceRechargeOverviewSource interface {
FinanceRechargeOverview(context.Context, FinanceRechargeOverviewQuery) (FinanceRechargeOverview, error)
}
// GetRechargeBillOverview 返回财务概览聚合:按日趋势、区域分布与谷歌实付覆盖度;筛选口径与账单列表一致。
func (h *Handler) GetRechargeBillOverview(c *gin.Context) {
options := shared.ListOptions(c)
@ -80,48 +122,6 @@ func (h *Handler) GetRechargeBillOverview(c *gin.Context) {
response.OK(c, rechargeBillOverviewDTO{Daily: []rechargeBillDailyBucketDTO{}, Regions: []rechargeBillRegionBucketDTO{}})
return
}
if source, ok := h.billSources[appCode]; ok {
query := legacyRechargeBillQuery{
Keyword: options.Keyword,
RechargeType: strings.TrimSpace(firstQuery(c, "recharge_type", "rechargeType")),
PaidState: strings.TrimSpace(firstQuery(c, "paid_state", "paidState")),
RegionID: queryInt64(c, "region_id", "regionId"),
StartAtMS: startAtMS,
EndAtMS: endAtMS,
}
overview := rechargeBillOverviewDTO{Daily: []rechargeBillDailyBucketDTO{}, Regions: []rechargeBillRegionBucketDTO{}}
if userFilter.IsEmpty() && sellerFilter.IsEmpty() {
var err error
overview, err = source.Overview(c.Request.Context(), query, tzOffsetMinutes)
if err != nil {
response.ServerError(c, "获取财务概览失败")
return
}
}
if err := h.applyFinanceCoinSellerOverview(c.Request.Context(), appCode, financeCoinSellerRechargeQuery{
RechargeType: strings.TrimSpace(firstQuery(c, "recharge_type", "rechargeType")),
Status: options.Status,
PaidState: query.PaidState,
Keyword: options.Keyword,
RegionID: query.RegionID,
StartAtMS: startAtMS,
EndAtMS: endAtMS,
TzOffsetMinutes: tzOffsetMinutes,
TargetUserID: sellerUserID,
HasUserOnlyFilter: userID > 0 && sellerUserID == 0,
}, &overview); err != nil {
response.ServerError(c, "获取财务概览失败")
return
}
h.fillApprovedWithdrawalStats(&overview, appCode, startAtMS, endAtMS)
response.OK(c, overview)
return
}
overview := rechargeBillOverviewDTO{
Daily: []rechargeBillDailyBucketDTO{},
Regions: []rechargeBillRegionBucketDTO{},
}
financeQuery := financeCoinSellerRechargeQuery{
RechargeType: strings.TrimSpace(firstQuery(c, "recharge_type", "rechargeType")),
Status: options.Status,
@ -136,21 +136,158 @@ 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),
AppCode: appCode,
RegionId: queryInt64(c, "region_id", "regionId"),
RechargeType: financeQuery.RechargeType,
Status: options.Status,
StartAtMs: startAtMS,
EndAtMs: endAtMS,
TzOffsetMinutes: tzOffsetMinutes,
})
// 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: query.RegionID,
RechargeType: query.RechargeType,
Status: query.Status,
StartAtMs: query.StartAtMS,
EndAtMs: query.EndAtMS,
TzOffsetMinutes: query.TzOffsetMinutes,
})
if err != nil {
return rechargeBillOverviewDTO{}, err
}
overview.Daily = make([]rechargeBillDailyBucketDTO, 0, len(resp.GetDaily()))
for _, bucket := range resp.GetDaily() {
overview.Daily = append(overview.Daily, rechargeBillDailyBucketDTO{
@ -179,20 +316,71 @@ func (h *Handler) GetRechargeBillOverview(c *gin.Context) {
overview.Withdrawal.TransferUsdMinor = transfer.GetTransferUsdMinor()
}
}
if normalizedRechargeType(financeQuery.RechargeType) == "" {
ordinaryRegions, err := h.walletOrdinaryRechargeOverviewRegions(c.Request.Context(), appCode, financeQuery)
if includeOrdinary && includeRegions && normalizedRechargeType(query.RechargeType) == "" {
ordinaryRegions, err := h.walletOrdinaryRechargeOverviewRegions(ctx, appCode, query)
if err != nil {
writeWalletError(c, err, "获取财务概览失败")
return
return rechargeBillOverviewDTO{}, err
}
overview.Regions = h.fillFinanceCoinSellerRegionNames(c.Request.Context(), appCode, ordinaryRegions)
overview.Regions = h.fillFinanceCoinSellerRegionNames(ctx, appCode, ordinaryRegions)
}
if err := h.applyFinanceCoinSellerOverview(c.Request.Context(), appCode, financeQuery, &overview); err != nil {
response.ServerError(c, "获取财务概览失败")
return
if err := h.applyFinanceCoinSellerOverview(ctx, appCode, query, &overview); err != nil {
return rechargeBillOverviewDTO{}, err
}
h.fillApprovedWithdrawalStats(&overview, appCode, startAtMS, endAtMS)
response.OK(c, overview)
return overview, nil
}
func financeRechargeDailyBuckets(rows []rechargeBillDailyBucketDTO) []FinanceRechargeDailyBucket {
out := make([]FinanceRechargeDailyBucket, 0, len(rows))
for _, row := range rows {
if strings.TrimSpace(row.Date) == "" {
continue
}
out = append(out, FinanceRechargeDailyBucket{
Date: row.Date,
GoogleUSDMinor: row.GoogleUsdMinor,
ThirdPartyUSDMinor: row.ThirdPartyUsdMinor,
CoinSellerUSDMinor: row.CoinSellerUsdMinor,
})
}
sort.Slice(out, func(i, j int) bool { return out[i].Date < out[j].Date })
return out
}
func mergeFinanceRechargeRegionDaily(regions []FinanceRechargeRegionOverview) []FinanceRechargeDailyBucket {
byDay := map[string]FinanceRechargeDailyBucket{}
for _, region := range regions {
for _, row := range region.Daily {
current := byDay[row.Date]
current.Date = row.Date
current.GoogleUSDMinor += row.GoogleUSDMinor
current.ThirdPartyUSDMinor += row.ThirdPartyUSDMinor
current.CoinSellerUSDMinor += row.CoinSellerUSDMinor
byDay[row.Date] = current
}
}
out := make([]FinanceRechargeDailyBucket, 0, len(byDay))
for _, row := range byDay {
out = append(out, row)
}
sort.Slice(out, func(i, j int) bool { return out[i].Date < out[j].Date })
return out
}
func uniquePositiveInt64s(values []int64) []int64 {
seen := map[int64]struct{}{}
out := make([]int64, 0, len(values))
for _, value := range values {
if value <= 0 {
continue
}
if _, ok := seen[value]; ok {
continue
}
seen[value] = struct{}{}
out = append(out, value)
}
sort.Slice(out, func(i, j int) bool { return out[i] < out[j] })
return out
}
func (h *Handler) requireLegacyCoinSellerDayRange(c *gin.Context, query legacyRechargeBillQuery, tzOffsetMinutes int32) bool {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -49,6 +49,8 @@ func (h *Handler) loginPassword(writer http.ResponseWriter, request *http.Reques
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"`
@ -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,
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)
}
func authRequestMetaWithLoginContext(request *http.Request, deviceID string, platform string, language string, timezone string) *userv1.RequestMeta {
return &userv1.RequestMeta{
RequestId: httpkit.RequestIDFromContext(request.Context()),
Caller: "gateway-service",
@ -508,6 +536,8 @@ func authRequestMetaWithLoginContext(request *http.Request, deviceID string, pla
Platform: strings.ToLower(strings.TrimSpace(platform)),
Language: strings.TrimSpace(language),
Timezone: strings.TrimSpace(timezone),
AppVersion: appVersion,
BuildNumber: buildNumber,
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -63,6 +63,7 @@ type fakeRoomClient struct {
lastUnban *roomv1.UnbanUserRequest
lastGift *roomv1.SendGiftRequest
sendGiftResp *roomv1.SendGiftResponse
sendGiftWaitForContext bool
lastFollowRoom *roomv1.FollowRoomRequest
lastUnfollowRoom *roomv1.UnfollowRoomRequest
createErr error
@ -274,8 +275,12 @@ func (f *fakeRoomClient) UnbanUser(_ context.Context, req *roomv1.UnbanUserReque
return &roomv1.UnbanUserResponse{Result: &roomv1.CommandResult{Applied: true, RoomVersion: 13}}, nil
}
func (f *fakeRoomClient) SendGift(_ context.Context, req *roomv1.SendGiftRequest) (*roomv1.SendGiftResponse, error) {
func (f *fakeRoomClient) SendGift(ctx context.Context, req *roomv1.SendGiftRequest) (*roomv1.SendGiftResponse, error) {
f.lastGift = req
if f.sendGiftWaitForContext {
<-ctx.Done()
return nil, status.FromContextError(ctx.Err()).Err()
}
if f.sendGiftResp != nil {
return f.sendGiftResp, nil
}
@ -7267,6 +7272,9 @@ func TestThirdPartyLoginUsesPublicEnvelopeAndPropagatesRequestID(t *testing.T) {
if req.GetMeta().GetRequestId() != generatedRequestID || req.GetMeta().GetClientIp() != "203.0.113.10" || req.GetMeta().GetUserAgent() != "hyapp-test/1.0" || req.GetMeta().GetCountryByIp() != "SG" {
t.Fatalf("auth meta was not propagated from gateway context: %+v", req.GetMeta())
}
if req.GetMeta().GetAppVersion() != "1.2.3" || req.GetMeta().GetBuildNumber() != "123" {
t.Fatalf("third-party body version fallback was not propagated into auth meta: %+v", req.GetMeta())
}
if req.GetUsername() != "hy" || req.GetInviteCode() != "INV-1" || req.GetDeviceId() != "dev-1" {
t.Fatalf("third-party registration fields were not propagated: %+v", req)
}
@ -7290,6 +7298,8 @@ func TestThirdPartyRegisterUsesPublicEnvelopeAndReturnsCompletedToken(t *testing
request.Header.Set("X-Forwarded-For", "203.0.113.10")
request.Header.Set("User-Agent", "hyapp-test/1.0")
request.Header.Set("CF-IPCountry", "sg")
request.Header.Set("X-App-Version", "2.0.0")
request.Header.Set("X-App-Build-Number", "200")
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, request)
@ -7319,6 +7329,9 @@ func TestThirdPartyRegisterUsesPublicEnvelopeAndReturnsCompletedToken(t *testing
if req.GetMeta().GetRequestId() != generatedRequestID || req.GetMeta().GetClientIp() != "203.0.113.10" || req.GetMeta().GetCountryByIp() != "SG" {
t.Fatalf("auth meta was not propagated from gateway context: %+v", req.GetMeta())
}
if req.GetMeta().GetAppVersion() != "2.0.0" || req.GetMeta().GetBuildNumber() != "200" || req.GetAppVersion() != "2.0.0" || req.GetBuildNumber() != "200" {
t.Fatalf("register version headers must override body values in both auth meta and registration fields: %+v meta=%+v", req, req.GetMeta())
}
if req.GetProvider() != "firebase" || req.GetCredential() != "firebase-id-token-1" || req.GetDeviceId() != "dev-1" {
t.Fatalf("provider credential device fields mismatch: %+v", req)
}
@ -7437,9 +7450,11 @@ func TestAccountLoginRateLimitByAccount(t *testing.T) {
func TestAccountLoginAcceptsAccountField(t *testing.T) {
userClient := &fakeUserAuthClient{}
router := NewHandler(&fakeRoomClient{}, userClient).Routes(auth.NewVerifier("secret"))
body := []byte(`{"account":"123456","password":"1234567","device_id":"ios","platform":"ios","language":"en-US","timezone":"America/Los_Angeles"}`)
body := []byte(`{"account":"123456","password":"1234567","device_id":"ios","app_version":"1.2.3","build_number":"123","platform":"ios","language":"en-US","timezone":"America/Los_Angeles"}`)
request := httptest.NewRequest(http.MethodPost, "/api/v1/auth/account/login", bytes.NewReader(body))
request.Header.Set("X-Request-ID", "req-account-login")
request.Header.Set("X-App-Version", "2.0.0")
request.Header.Set("X-App-Build-Number", "200")
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, request)
@ -7467,6 +7482,9 @@ func TestAccountLoginAcceptsAccountField(t *testing.T) {
if userClient.lastLoginPassword.GetMeta().GetPlatform() != "ios" || userClient.lastLoginPassword.GetMeta().GetLanguage() != "en-US" || userClient.lastLoginPassword.GetMeta().GetTimezone() != "America/Los_Angeles" {
t.Fatalf("password login risk meta mismatch: %+v", userClient.lastLoginPassword.GetMeta())
}
if userClient.lastLoginPassword.GetMeta().GetAppVersion() != "2.0.0" || userClient.lastLoginPassword.GetMeta().GetBuildNumber() != "200" {
t.Fatalf("password login version headers must override body fallback: %+v", userClient.lastLoginPassword.GetMeta())
}
}
func TestQuickCreateAccountCreatesCompletedPasswordAccount(t *testing.T) {
@ -7476,6 +7494,8 @@ func TestQuickCreateAccountCreatesCompletedPasswordAccount(t *testing.T) {
request := httptest.NewRequest(http.MethodPost, "/api/v1/auth/account/quick-create", bytes.NewReader(body))
request.Header.Set("X-Request-ID", "req-quick-create")
request.Header.Set("X-App-Code", "lalu")
request.Header.Set("X-App-Version", "3.0.0")
request.Header.Set("X-App-Build-Number", "300")
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, request)
@ -7506,6 +7526,9 @@ func TestQuickCreateAccountCreatesCompletedPasswordAccount(t *testing.T) {
if req.GetMeta().GetAppCode() != "lalu" || req.GetMeta().GetDeviceId() != req.GetDeviceId() {
t.Fatalf("quick create meta mismatch: %+v", req.GetMeta())
}
if req.GetMeta().GetAppVersion() != "3.0.0" || req.GetMeta().GetBuildNumber() != "300" || req.GetAppVersion() != "3.0.0" || req.GetBuildNumber() != "300" {
t.Fatalf("quick create version headers were not propagated consistently: %+v meta=%+v", req, req.GetMeta())
}
data, ok := response.Data.(map[string]any)
if !ok || data["uid"] != "100001" || data["account"] != "100001" || data["access_token"] != "access-quick" || data["password_set"] != true {
@ -7666,6 +7689,27 @@ func TestRefreshRateLimitByDeviceID(t *testing.T) {
}
}
func TestRefreshPropagatesCurrentClientVersionWithHeaderPriority(t *testing.T) {
userClient := &fakeUserAuthClient{}
router := NewHandler(&fakeRoomClient{}, userClient).Routes(auth.NewVerifier("secret"))
body := []byte(`{"refresh_token":"refresh-1","device_id":"dev-1","app_version":"1.2.3","build_number":"123"}`)
request := httptest.NewRequest(http.MethodPost, "/api/v1/auth/token/refresh", bytes.NewReader(body))
request.Header.Set("X-Request-ID", "req-refresh-version")
request.Header.Set("X-App-Version", "2.0.0")
request.Header.Set("X-App-Build-Number", "200")
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK {
t.Fatalf("status mismatch: got=%d body=%s", recorder.Code, recorder.Body.String())
}
meta := userClient.lastRefresh.GetMeta()
if meta.GetAppVersion() != "2.0.0" || meta.GetBuildNumber() != "200" {
t.Fatalf("refresh client version mismatch: %+v", meta)
}
}
func TestLogoutRateLimitBySessionIDAndIP(t *testing.T) {
userClient := &fakeUserAuthClient{}
handler := NewHandler(&fakeRoomClient{}, userClient)

View File

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

View File

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

View File

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

View File

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

View File

@ -7,6 +7,7 @@ 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"
)
@ -22,10 +23,17 @@ type Handler struct {
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 内,生产使用固定安全值;测试可缩短预算验证真实超时分支,
@ -47,10 +55,33 @@ type Config struct {
WalletClient client.WalletClient
GrowthLevelClient client.GrowthLevelClient
AchievementClient client.AchievementClient
GiftCapacityEnabled bool
GiftCapacityLimiter giftlimit.Limiter
GiftRequestTimeout time.Duration
GiftMaxTargets int
GiftMaxCount int
GiftMaxUnits int
RTCTokenConfig tencentrtc.TokenConfig
}
func New(config Config) *Handler {
giftRequestTimeout := config.GiftRequestTimeout
if giftRequestTimeout <= 0 {
giftRequestTimeout = 5 * time.Second
}
giftMaxTargets := config.GiftMaxTargets
if giftMaxTargets <= 0 {
giftMaxTargets = 30
}
giftMaxCount := config.GiftMaxCount
if giftMaxCount <= 0 {
giftMaxCount = 999
}
giftMaxUnits := config.GiftMaxUnits
if giftMaxUnits <= 0 {
giftMaxUnits = 5_000
}
comboReader, _ := any(config.AppConfigReader).(giftComboConfigReader)
return &Handler{
roomClient: config.RoomClient,
roomGuardClient: config.RoomGuardClient,
@ -61,10 +92,17 @@ func New(config Config) *Handler {
userSocialClient: config.UserSocialClient,
userHostClient: config.UserHostClient,
appConfigReader: config.AppConfigReader,
giftComboReader: comboReader,
walletClient: config.WalletClient,
giftPanelCache: newRoomGiftPanelConfigCache(45 * time.Second),
growthLevelClient: config.GrowthLevelClient,
achievementClient: config.AchievementClient,
giftCapacityEnabled: config.GiftCapacityEnabled,
giftCapacityLimiter: config.GiftCapacityLimiter,
giftRequestTimeout: giftRequestTimeout,
giftMaxTargets: giftMaxTargets,
giftMaxCount: giftMaxCount,
giftMaxUnits: giftMaxUnits,
rtcTokenConfig: config.RTCTokenConfig,
giftPanelTotalBudget: roomGiftPanelTotalBudget,
giftPanelRecipientCoreBudget: roomGiftPanelRecipientCoreBudget,
@ -112,6 +150,7 @@ func (h *Handler) RoomHandlers() httproutes.RoomHandlers {
UnbanUser: h.unbanUser,
SendGift: h.sendGift,
SendGiftBatch: h.sendGiftBatch,
SendGiftV2: h.sendGiftV2,
}
}

View File

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

View File

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

View File

@ -35,6 +35,12 @@ func (h *Handler) Routes(jwtVerifier *auth.Verifier) http.Handler {
WalletClient: h.walletClient,
GrowthLevelClient: h.growthLevelClient,
AchievementClient: h.achievementClient,
GiftCapacityEnabled: h.giftCapacityEnabled,
GiftCapacityLimiter: h.giftCapacityLimiter,
GiftRequestTimeout: h.giftRequestTimeout,
GiftMaxTargets: h.giftMaxTargets,
GiftMaxCount: h.giftMaxCount,
GiftMaxUnits: h.giftMaxUnits,
RTCTokenConfig: tencentrtc.TokenConfig{
Enabled: h.tencentRTC.Enabled,
SDKAppID: h.tencentRTC.SDKAppID,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -10,6 +10,7 @@ import (
"hyapp/pkg/configx"
"hyapp/pkg/logx"
"hyapp/pkg/roommq"
"hyapp/pkg/tencentim"
"hyapp/pkg/tencentrtc"
)
@ -149,6 +150,9 @@ type RocketMQConfig struct {
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"`
@ -344,6 +348,7 @@ func defaultRocketMQConfig() RocketMQConfig {
Enabled: false,
SendTimeout: 3 * time.Second,
Retry: 2,
RoomEventTagMode: roommq.EventTagModeLegacy,
RoomOutbox: RoomOutboxMQConfig{
Enabled: false,
Topic: "hyapp_room_outbox",
@ -552,6 +557,17 @@ func normalizeRocketMQConfig(cfg RocketMQConfig, publishMode string) (RocketMQCo
if cfg.Retry < 0 {
cfg.Retry = defaults.Retry
}
cfg.RoomEventTagMode = roommq.EventTagMode(strings.ToLower(strings.TrimSpace(string(cfg.RoomEventTagMode))))
if cfg.RoomEventTagMode == "" {
// 旧线上 YAML 没有此字段时必须继续发 legacy Tag切 Phase B 必须是一次
// 显式配置变更,不能由普通代码滚动发布顺带改变 MQ 可见性。
cfg.RoomEventTagMode = defaults.RoomEventTagMode
}
switch cfg.RoomEventTagMode {
case roommq.EventTagModeLegacy, roommq.EventTagModeEventType:
default:
return RocketMQConfig{}, fmt.Errorf("rocketmq.room_event_tag_mode must be legacy or event_type")
}
if cfg.RoomOutbox.Topic = strings.TrimSpace(cfg.RoomOutbox.Topic); cfg.RoomOutbox.Topic == "" {
cfg.RoomOutbox.Topic = defaults.RoomOutbox.Topic
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -11,6 +11,7 @@ import (
roomv1 "hyapp.local/api/proto/room/v1"
walletv1 "hyapp.local/api/proto/wallet/v1"
"hyapp/pkg/appcode"
"hyapp/pkg/giftlimits"
"hyapp/pkg/tencentim"
"hyapp/pkg/xerr"
"hyapp/services/room-service/internal/room/command"
@ -52,6 +53,13 @@ type giftFlow struct {
batchDisplay *roomv1.SendGiftBatchDisplay
batchDisplayEnabled bool
suppressDirectIMEventTypes map[string]bool
// operationStarted 后任何失败都必须由服务端更新 saga客户端断线不能成为扣款后完成结算的必要条件。
operationStarted bool
debitCompleted bool
drawCompleted bool
recovering bool
operation GiftOperation
}
func newGiftFlow(s *Service, ctx context.Context, req *roomv1.SendGiftRequest, options giftSendOptions) *giftFlow {
@ -70,6 +78,8 @@ func newGiftFlow(s *Service, ctx context.Context, req *roomv1.SendGiftRequest, o
EntitlementID: strings.TrimSpace(req.GetEntitlementId()),
Source: strings.TrimSpace(req.GetSource()),
DisplayMode: displayMode,
ComboSessionID: strings.TrimSpace(req.GetComboSessionId()),
BatchSeq: req.GetBatchSeq(),
SenderCountryID: req.GetSenderCountryId(),
SenderRegionID: req.GetSenderRegionId(),
TargetIsHost: req.GetTargetIsHost(),
@ -96,10 +106,12 @@ func newGiftFlow(s *Service, ctx context.Context, req *roomv1.SendGiftRequest, o
}
func (f *giftFlow) validateBeforeDebit(current *state.RoomState) error {
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。
return xerr.New(xerr.InvalidArgument, "target_type is unsupported")
@ -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,6 +221,11 @@ func (f *giftFlow) prepareRoomFacts() error {
}
f.roomMeta = roomMeta
if f.cmd.PoolID != "" && !f.cmd.SyntheticLuckyGift {
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")
@ -213,6 +246,7 @@ func (f *giftFlow) prepareRoomFacts() error {
// Check 只说明“允许按该 pool 抽奖”,真实价格、区域可用性和扣费仍以 wallet-service 为准。
f.luckyEnabled = true
}
}
rocketConfig, err := f.s.roomRocketConfig(f.ctx)
if err != nil {
return err
@ -241,6 +275,10 @@ func (f *giftFlow) debitAndSettle(now time.Time) error {
f.settledCommand.GiftTypeCode = f.billing.GetGiftTypeCode()
f.settledCommand.HostPeriodDiamondAdded = f.billing.GetHostPeriodDiamondAdded()
f.settledCommand.HostPeriodCycleKey = f.billing.GetHostPeriodCycleKey()
if err := f.markGiftOperationDebited(now); err != nil {
// wallet 已返回稳定回执但 saga 落盘失败时不能继续抽奖;后台会用相同 command_id 重取钱包回执后推进。
return err
}
if f.cmd.SyntheticLuckyGift {
f.luckyGift = syntheticLuckyGiftResult(f.cmd, f.targetBillings, now)
if f.luckyGift != nil {
@ -261,6 +299,10 @@ func (f *giftFlow) debitAndSettle(now time.Time) error {
f.luckyGift = aggregateLuckyGiftResults(f.cmd.ID(), f.luckyGifts)
}
}
if err := f.markGiftOperationDrawn(now); err != nil {
// 抽奖 owner 已按 command_id 幂等;即使进度写入失败,恢复也只会重放同一结果,不会再次产生奖金。
return err
}
return nil
})
}
@ -488,6 +530,27 @@ func (f *giftFlow) toMutationResult(now time.Time, current *state.RoomState) mut
},
},
}
result.giftV2 = &roomv1.SendGiftResultV2{
ApiVersion: 2,
CommandId: f.cmd.ID(),
ComboSessionId: f.cmd.ComboSessionID,
BatchSeq: f.cmd.BatchSeq,
IdempotentReplay: false,
CommittedRoomVersion: current.Version,
BillingReceiptId: f.billing.GetBillingReceiptId(),
CoinBalanceAfter: finalCoinBalanceAfter,
GiftIncomeBalanceAfter: f.billing.GetGiftIncomeBalanceAfter(),
SettledGiftCount: f.cmd.GiftCount,
SettledTargetUserIds: append([]int64(nil), f.cmd.TargetUserIDs...),
BalanceVersion: f.billing.GetBalanceVersion(),
LuckyGift: f.luckyGift,
LuckyGifts: append([]*roomv1.LuckyGiftDrawResult(nil), f.luckyGifts...),
LuckyHits: luckyHitsFromResults(f.luckyGifts),
BatchDisplay: f.batchDisplay,
RoomHeat: current.Heat,
GiftRank: cloneProtoRank(current.GiftRank),
OperationStatus: GiftOperationStatusCommitted,
}
if f.cmd.RobotGift {
// 机器人房间可以增加房间热度和麦位收礼热度,但不进入跨房礼物榜和房间送礼统计。
result.roomGiftLeaderboard = nil

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -3,10 +3,12 @@ package service
import (
"bytes"
"context"
"errors"
"fmt"
"time"
"hyapp/pkg/appcode"
"hyapp/pkg/giftlimits"
"hyapp/pkg/xerr"
"hyapp/services/room-service/internal/room/command"
"hyapp/services/room-service/internal/room/outbox"
@ -15,7 +17,10 @@ import (
"hyapp/services/room-service/internal/router"
)
const roomMutationCommitTimeout = 5 * time.Second
const (
roomMutationCommitTimeout = 5 * time.Second
roomMutationConfirmCommitTimeout = 2 * time.Second
)
func (s *Service) mutateRoom(ctx context.Context, cmd command.Command, replayable bool, mutate func(now time.Time, current *state.RoomState, localRank *rank.LocalRank) (mutationResult, []outbox.Record, error)) (mutationResult, error) {
startedAt := time.Now()
@ -28,25 +33,31 @@ func (s *Service) mutateRoom(ctx context.Context, cmd command.Command, replayabl
// command meta 是幂等、路由、审计和权限判断的共同基础,缺一不可。
return mutationResult{}, xerr.New(xerr.InvalidArgument, "command meta is incomplete")
}
if !giftlimits.ValidCommandID(cmd.ID()) {
// room_command_log.command_id 是 VARCHAR(128);入口显式拒绝比依赖 MySQL 截断/报错更安全,
// 同时确保 wallet、lucky-gift 和 Room Cell 使用完全相同的稳定幂等键。
return mutationResult{}, xerr.New(xerr.InvalidArgument, "command_id is too long")
}
if s.isDraining() {
// 下线中的节点只让已经进入执行链路的命令收尾,不再接收新的 Room Cell 命令。
return mutationResult{}, xerr.New(xerr.Unavailable, "room service is draining")
}
idempotencyStartedAt := time.Now()
payload, seen, err := s.commandPayloadForIdempotency(ctx, cmd)
payload, existingCommand, seen, err := s.commandRecordForIdempotency(ctx, cmd)
idempotencyMS = elapsedMS(idempotencyStartedAt)
if err != nil {
return mutationResult{}, err
}
if seen {
// 幂等命令不重新执行 mutate也不会再次调用 wallet 或写 outbox。
// 幂等命令不重新执行 mutate也不会再次调用 wallet 或写 outboxSendGift V2 从首次 result_payload
// 重放账务和幸运礼物结果,旧命令没有结果时才维持历史 snapshot-only 兼容语义。
snapshot, err := s.currentSnapshot(ctx, cmd.RoomID())
if err != nil {
return mutationResult{}, err
}
return mutationResult{snapshot: snapshot}, nil
return replayMutationResult(existingCommand, snapshot)
}
// 确保当前节点持有房间 lease并在没有内存 Cell 时完成恢复。
@ -58,6 +69,15 @@ func (s *Service) mutateRoom(ctx context.Context, cmd command.Command, replayabl
}
value, err := roomCell.Do(ctx, func(current *state.RoomState, currentRank *rank.LocalRank) (any, error) {
// 外层预检与进入 Room Cell 之间可能已有相同 command_id 完成提交;必须在串行临界区内权威重查,
// 否则第二个请求会复用 wallet 幂等回执却再次增加房间热度、榜单和 outbox。
_, committedCommand, alreadyCommitted, err := s.commandRecordForIdempotency(ctx, cmd)
if err != nil {
return nil, err
}
if alreadyCommitted {
return replayMutationResult(committedCommand, snapshotWithApp(ctx, current.ToProto()))
}
// 在副本上执行命令,只有 command log/outbox 保存成功后才整体替换当前状态。
nextState := current.Clone()
nextRank := currentRank.Clone()
@ -94,19 +114,26 @@ func (s *Service) mutateRoom(ctx context.Context, cmd command.Command, replayabl
directIMRecords = append(directIMRecords, result.directIMRecords...)
saveStartedAt := time.Now()
commitCtx, commitCancel := roomMutationCommitContext(ctx)
err := s.repository.SaveMutation(commitCtx, MutationCommit{
Command: CommandRecord{
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,11 +145,30 @@ 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 err != nil {
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 {
// 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 业务事实,避免外部慢调用占住房间串行执行队列。
result.outboxRecords = durableOutboxRecords
@ -148,8 +194,7 @@ 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{
commandRecord := CommandRecord{
AppCode: appcode.FromContext(ctx),
RoomID: cmd.RoomID(),
RoomVersion: current.Version,
@ -160,10 +205,10 @@ func (s *Service) mutateRoom(ctx context.Context, cmd command.Command, replayabl
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