diff --git a/api/proto/luckygift/v1/luckygift.pb.go b/api/proto/luckygift/v1/luckygift.pb.go index f57fe7d2..c3322319 100644 --- a/api/proto/luckygift/v1/luckygift.pb.go +++ b/api/proto/luckygift/v1/luckygift.pb.go @@ -2273,15 +2273,18 @@ type LuckyGiftPoolBalance struct { sizeCache protoimpl.SizeCache unknownFields protoimpl.UnknownFields - AppCode string `protobuf:"bytes,1,opt,name=app_code,json=appCode,proto3" json:"app_code,omitempty"` - PoolId string `protobuf:"bytes,2,opt,name=pool_id,json=poolId,proto3" json:"pool_id,omitempty"` - Balance int64 `protobuf:"varint,3,opt,name=balance,proto3" json:"balance,omitempty"` - ReserveFloor int64 `protobuf:"varint,4,opt,name=reserve_floor,json=reserveFloor,proto3" json:"reserve_floor,omitempty"` - AvailableBalance int64 `protobuf:"varint,5,opt,name=available_balance,json=availableBalance,proto3" json:"available_balance,omitempty"` - TotalIn int64 `protobuf:"varint,6,opt,name=total_in,json=totalIn,proto3" json:"total_in,omitempty"` - TotalOut int64 `protobuf:"varint,7,opt,name=total_out,json=totalOut,proto3" json:"total_out,omitempty"` - Materialized bool `protobuf:"varint,8,opt,name=materialized,proto3" json:"materialized,omitempty"` - UpdatedAtMs int64 `protobuf:"varint,9,opt,name=updated_at_ms,json=updatedAtMs,proto3" json:"updated_at_ms,omitempty"` + AppCode string `protobuf:"bytes,1,opt,name=app_code,json=appCode,proto3" json:"app_code,omitempty"` + PoolId string `protobuf:"bytes,2,opt,name=pool_id,json=poolId,proto3" json:"pool_id,omitempty"` + Balance int64 `protobuf:"varint,3,opt,name=balance,proto3" json:"balance,omitempty"` + ReserveFloor int64 `protobuf:"varint,4,opt,name=reserve_floor,json=reserveFloor,proto3" json:"reserve_floor,omitempty"` + AvailableBalance int64 `protobuf:"varint,5,opt,name=available_balance,json=availableBalance,proto3" json:"available_balance,omitempty"` + TotalIn int64 `protobuf:"varint,6,opt,name=total_in,json=totalIn,proto3" json:"total_in,omitempty"` + TotalOut int64 `protobuf:"varint,7,opt,name=total_out,json=totalOut,proto3" json:"total_out,omitempty"` + Materialized bool `protobuf:"varint,8,opt,name=materialized,proto3" json:"materialized,omitempty"` + UpdatedAtMs int64 `protobuf:"varint,9,opt,name=updated_at_ms,json=updatedAtMs,proto3" json:"updated_at_ms,omitempty"` + StrategyVersion string `protobuf:"bytes,10,opt,name=strategy_version,json=strategyVersion,proto3" json:"strategy_version,omitempty"` + ManualCreditTotal int64 `protobuf:"varint,11,opt,name=manual_credit_total,json=manualCreditTotal,proto3" json:"manual_credit_total,omitempty"` + ManualDebitTotal int64 `protobuf:"varint,12,opt,name=manual_debit_total,json=manualDebitTotal,proto3" json:"manual_debit_total,omitempty"` } func (x *LuckyGiftPoolBalance) Reset() { @@ -2377,6 +2380,27 @@ func (x *LuckyGiftPoolBalance) GetUpdatedAtMs() int64 { return 0 } +func (x *LuckyGiftPoolBalance) GetStrategyVersion() string { + if x != nil { + return x.StrategyVersion + } + return "" +} + +func (x *LuckyGiftPoolBalance) GetManualCreditTotal() int64 { + if x != nil { + return x.ManualCreditTotal + } + return 0 +} + +func (x *LuckyGiftPoolBalance) GetManualDebitTotal() int64 { + if x != nil { + return x.ManualDebitTotal + } + return 0 +} + type GetLuckyGiftDrawSummaryRequest struct { state protoimpl.MessageState sizeCache protoimpl.SizeCache @@ -2528,8 +2552,9 @@ type ListLuckyGiftPoolBalancesRequest struct { sizeCache protoimpl.SizeCache unknownFields protoimpl.UnknownFields - Meta *RequestMeta `protobuf:"bytes,1,opt,name=meta,proto3" json:"meta,omitempty"` - PoolId string `protobuf:"bytes,2,opt,name=pool_id,json=poolId,proto3" json:"pool_id,omitempty"` + Meta *RequestMeta `protobuf:"bytes,1,opt,name=meta,proto3" json:"meta,omitempty"` + PoolId string `protobuf:"bytes,2,opt,name=pool_id,json=poolId,proto3" json:"pool_id,omitempty"` + StrategyVersion string `protobuf:"bytes,3,opt,name=strategy_version,json=strategyVersion,proto3" json:"strategy_version,omitempty"` } func (x *ListLuckyGiftPoolBalancesRequest) Reset() { @@ -2576,6 +2601,13 @@ func (x *ListLuckyGiftPoolBalancesRequest) GetPoolId() string { return "" } +func (x *ListLuckyGiftPoolBalancesRequest) GetStrategyVersion() string { + if x != nil { + return x.StrategyVersion + } + return "" +} + type ListLuckyGiftPoolBalancesResponse struct { state protoimpl.MessageState sizeCache protoimpl.SizeCache @@ -2700,6 +2732,295 @@ func (x *LuckyGiftHit) GetRewardCoins() int64 { return 0 } +// 调账消息也只追加在文件尾,保持所有既有 protobuf message index 稳定。 +// adjustment_id 是业务幂等键;RequestMeta.request_id 只做链路追踪,不能替代资金幂等语义。 +type AdjustLuckyGiftPoolBalanceRequest struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Meta *RequestMeta `protobuf:"bytes,1,opt,name=meta,proto3" json:"meta,omitempty"` + PoolId string `protobuf:"bytes,2,opt,name=pool_id,json=poolId,proto3" json:"pool_id,omitempty"` + StrategyVersion string `protobuf:"bytes,3,opt,name=strategy_version,json=strategyVersion,proto3" json:"strategy_version,omitempty"` + AdjustmentId string `protobuf:"bytes,4,opt,name=adjustment_id,json=adjustmentId,proto3" json:"adjustment_id,omitempty"` + Direction string `protobuf:"bytes,5,opt,name=direction,proto3" json:"direction,omitempty"` + AmountCoins int64 `protobuf:"varint,6,opt,name=amount_coins,json=amountCoins,proto3" json:"amount_coins,omitempty"` + Reason string `protobuf:"bytes,7,opt,name=reason,proto3" json:"reason,omitempty"` + OperatorAdminId int64 `protobuf:"varint,8,opt,name=operator_admin_id,json=operatorAdminId,proto3" json:"operator_admin_id,omitempty"` +} + +func (x *AdjustLuckyGiftPoolBalanceRequest) Reset() { + *x = AdjustLuckyGiftPoolBalanceRequest{} + mi := &file_proto_luckygift_v1_luckygift_proto_msgTypes[29] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *AdjustLuckyGiftPoolBalanceRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*AdjustLuckyGiftPoolBalanceRequest) ProtoMessage() {} + +func (x *AdjustLuckyGiftPoolBalanceRequest) ProtoReflect() protoreflect.Message { + mi := &file_proto_luckygift_v1_luckygift_proto_msgTypes[29] + 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 AdjustLuckyGiftPoolBalanceRequest.ProtoReflect.Descriptor instead. +func (*AdjustLuckyGiftPoolBalanceRequest) Descriptor() ([]byte, []int) { + return file_proto_luckygift_v1_luckygift_proto_rawDescGZIP(), []int{29} +} + +func (x *AdjustLuckyGiftPoolBalanceRequest) GetMeta() *RequestMeta { + if x != nil { + return x.Meta + } + return nil +} + +func (x *AdjustLuckyGiftPoolBalanceRequest) GetPoolId() string { + if x != nil { + return x.PoolId + } + return "" +} + +func (x *AdjustLuckyGiftPoolBalanceRequest) GetStrategyVersion() string { + if x != nil { + return x.StrategyVersion + } + return "" +} + +func (x *AdjustLuckyGiftPoolBalanceRequest) GetAdjustmentId() string { + if x != nil { + return x.AdjustmentId + } + return "" +} + +func (x *AdjustLuckyGiftPoolBalanceRequest) GetDirection() string { + if x != nil { + return x.Direction + } + return "" +} + +func (x *AdjustLuckyGiftPoolBalanceRequest) GetAmountCoins() int64 { + if x != nil { + return x.AmountCoins + } + return 0 +} + +func (x *AdjustLuckyGiftPoolBalanceRequest) GetReason() string { + if x != nil { + return x.Reason + } + return "" +} + +func (x *AdjustLuckyGiftPoolBalanceRequest) GetOperatorAdminId() int64 { + if x != nil { + return x.OperatorAdminId + } + return 0 +} + +type LuckyGiftPoolAdjustment struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + AdjustmentId string `protobuf:"bytes,1,opt,name=adjustment_id,json=adjustmentId,proto3" json:"adjustment_id,omitempty"` + AppCode string `protobuf:"bytes,2,opt,name=app_code,json=appCode,proto3" json:"app_code,omitempty"` + PoolId string `protobuf:"bytes,3,opt,name=pool_id,json=poolId,proto3" json:"pool_id,omitempty"` + StrategyVersion string `protobuf:"bytes,4,opt,name=strategy_version,json=strategyVersion,proto3" json:"strategy_version,omitempty"` + Direction string `protobuf:"bytes,5,opt,name=direction,proto3" json:"direction,omitempty"` + AmountCoins int64 `protobuf:"varint,6,opt,name=amount_coins,json=amountCoins,proto3" json:"amount_coins,omitempty"` + Reason string `protobuf:"bytes,7,opt,name=reason,proto3" json:"reason,omitempty"` + OperatorAdminId int64 `protobuf:"varint,8,opt,name=operator_admin_id,json=operatorAdminId,proto3" json:"operator_admin_id,omitempty"` + BalanceBefore int64 `protobuf:"varint,9,opt,name=balance_before,json=balanceBefore,proto3" json:"balance_before,omitempty"` + BalanceAfter int64 `protobuf:"varint,10,opt,name=balance_after,json=balanceAfter,proto3" json:"balance_after,omitempty"` + CreatedAtMs int64 `protobuf:"varint,11,opt,name=created_at_ms,json=createdAtMs,proto3" json:"created_at_ms,omitempty"` +} + +func (x *LuckyGiftPoolAdjustment) Reset() { + *x = LuckyGiftPoolAdjustment{} + mi := &file_proto_luckygift_v1_luckygift_proto_msgTypes[30] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *LuckyGiftPoolAdjustment) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*LuckyGiftPoolAdjustment) ProtoMessage() {} + +func (x *LuckyGiftPoolAdjustment) ProtoReflect() protoreflect.Message { + mi := &file_proto_luckygift_v1_luckygift_proto_msgTypes[30] + 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 LuckyGiftPoolAdjustment.ProtoReflect.Descriptor instead. +func (*LuckyGiftPoolAdjustment) Descriptor() ([]byte, []int) { + return file_proto_luckygift_v1_luckygift_proto_rawDescGZIP(), []int{30} +} + +func (x *LuckyGiftPoolAdjustment) GetAdjustmentId() string { + if x != nil { + return x.AdjustmentId + } + return "" +} + +func (x *LuckyGiftPoolAdjustment) GetAppCode() string { + if x != nil { + return x.AppCode + } + return "" +} + +func (x *LuckyGiftPoolAdjustment) GetPoolId() string { + if x != nil { + return x.PoolId + } + return "" +} + +func (x *LuckyGiftPoolAdjustment) GetStrategyVersion() string { + if x != nil { + return x.StrategyVersion + } + return "" +} + +func (x *LuckyGiftPoolAdjustment) GetDirection() string { + if x != nil { + return x.Direction + } + return "" +} + +func (x *LuckyGiftPoolAdjustment) GetAmountCoins() int64 { + if x != nil { + return x.AmountCoins + } + return 0 +} + +func (x *LuckyGiftPoolAdjustment) GetReason() string { + if x != nil { + return x.Reason + } + return "" +} + +func (x *LuckyGiftPoolAdjustment) GetOperatorAdminId() int64 { + if x != nil { + return x.OperatorAdminId + } + return 0 +} + +func (x *LuckyGiftPoolAdjustment) GetBalanceBefore() int64 { + if x != nil { + return x.BalanceBefore + } + return 0 +} + +func (x *LuckyGiftPoolAdjustment) GetBalanceAfter() int64 { + if x != nil { + return x.BalanceAfter + } + return 0 +} + +func (x *LuckyGiftPoolAdjustment) GetCreatedAtMs() int64 { + if x != nil { + return x.CreatedAtMs + } + return 0 +} + +type AdjustLuckyGiftPoolBalanceResponse struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Adjustment *LuckyGiftPoolAdjustment `protobuf:"bytes,1,opt,name=adjustment,proto3" json:"adjustment,omitempty"` + Pool *LuckyGiftPoolBalance `protobuf:"bytes,2,opt,name=pool,proto3" json:"pool,omitempty"` + IdempotentReplay bool `protobuf:"varint,3,opt,name=idempotent_replay,json=idempotentReplay,proto3" json:"idempotent_replay,omitempty"` +} + +func (x *AdjustLuckyGiftPoolBalanceResponse) Reset() { + *x = AdjustLuckyGiftPoolBalanceResponse{} + mi := &file_proto_luckygift_v1_luckygift_proto_msgTypes[31] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *AdjustLuckyGiftPoolBalanceResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*AdjustLuckyGiftPoolBalanceResponse) ProtoMessage() {} + +func (x *AdjustLuckyGiftPoolBalanceResponse) ProtoReflect() protoreflect.Message { + mi := &file_proto_luckygift_v1_luckygift_proto_msgTypes[31] + 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 AdjustLuckyGiftPoolBalanceResponse.ProtoReflect.Descriptor instead. +func (*AdjustLuckyGiftPoolBalanceResponse) Descriptor() ([]byte, []int) { + return file_proto_luckygift_v1_luckygift_proto_rawDescGZIP(), []int{31} +} + +func (x *AdjustLuckyGiftPoolBalanceResponse) GetAdjustment() *LuckyGiftPoolAdjustment { + if x != nil { + return x.Adjustment + } + return nil +} + +func (x *AdjustLuckyGiftPoolBalanceResponse) GetPool() *LuckyGiftPoolBalance { + if x != nil { + return x.Pool + } + return nil +} + +func (x *AdjustLuckyGiftPoolBalanceResponse) GetIdempotentReplay() bool { + if x != nil { + return x.IdempotentReplay + } + return false +} + var File_proto_luckygift_v1_luckygift_proto protoreflect.FileDescriptor var file_proto_luckygift_v1_luckygift_proto_rawDesc = []byte{ @@ -3184,7 +3505,7 @@ var file_proto_luckygift_v1_luckygift_proto_rawDesc = []byte{ 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, + 0x64, 0x44, 0x72, 0x61, 0x77, 0x73, 0x22, 0xbf, 0x03, 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, @@ -3203,137 +3524,217 @@ var file_proto_luckygift_v1_luckygift_proto_rawDesc = []byte{ 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, 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, 0x03, 0x52, 0x0b, 0x75, 0x70, 0x64, 0x61, 0x74, 0x65, 0x64, 0x41, 0x74, 0x4d, 0x73, 0x12, + 0x29, 0x0a, 0x10, 0x73, 0x74, 0x72, 0x61, 0x74, 0x65, 0x67, 0x79, 0x5f, 0x76, 0x65, 0x72, 0x73, + 0x69, 0x6f, 0x6e, 0x18, 0x0a, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0f, 0x73, 0x74, 0x72, 0x61, 0x74, + 0x65, 0x67, 0x79, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x2e, 0x0a, 0x13, 0x6d, 0x61, + 0x6e, 0x75, 0x61, 0x6c, 0x5f, 0x63, 0x72, 0x65, 0x64, 0x69, 0x74, 0x5f, 0x74, 0x6f, 0x74, 0x61, + 0x6c, 0x18, 0x0b, 0x20, 0x01, 0x28, 0x03, 0x52, 0x11, 0x6d, 0x61, 0x6e, 0x75, 0x61, 0x6c, 0x43, + 0x72, 0x65, 0x64, 0x69, 0x74, 0x54, 0x6f, 0x74, 0x61, 0x6c, 0x12, 0x2c, 0x0a, 0x12, 0x6d, 0x61, + 0x6e, 0x75, 0x61, 0x6c, 0x5f, 0x64, 0x65, 0x62, 0x69, 0x74, 0x5f, 0x74, 0x6f, 0x74, 0x61, 0x6c, + 0x18, 0x0c, 0x20, 0x01, 0x28, 0x03, 0x52, 0x10, 0x6d, 0x61, 0x6e, 0x75, 0x61, 0x6c, 0x44, 0x65, + 0x62, 0x69, 0x74, 0x54, 0x6f, 0x74, 0x61, 0x6c, 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, 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, + 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, 0x9b, 0x01, 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, 0x12, 0x29, 0x0a, 0x10, 0x73, 0x74, 0x72, 0x61, 0x74, 0x65, 0x67, + 0x79, 0x5f, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, + 0x0f, 0x73, 0x74, 0x72, 0x61, 0x74, 0x65, 0x67, 0x79, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, + 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, 0x22, 0xc6, 0x02, 0x0a, 0x21, 0x41, 0x64, 0x6a, 0x75, 0x73, 0x74, 0x4c, 0x75, 0x63, + 0x6b, 0x79, 0x47, 0x69, 0x66, 0x74, 0x50, 0x6f, 0x6f, 0x6c, 0x42, 0x61, 0x6c, 0x61, 0x6e, 0x63, + 0x65, 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, 0x12, 0x29, 0x0a, 0x10, 0x73, 0x74, 0x72, 0x61, 0x74, 0x65, + 0x67, 0x79, 0x5f, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, + 0x52, 0x0f, 0x73, 0x74, 0x72, 0x61, 0x74, 0x65, 0x67, 0x79, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, + 0x6e, 0x12, 0x23, 0x0a, 0x0d, 0x61, 0x64, 0x6a, 0x75, 0x73, 0x74, 0x6d, 0x65, 0x6e, 0x74, 0x5f, + 0x69, 0x64, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0c, 0x61, 0x64, 0x6a, 0x75, 0x73, 0x74, + 0x6d, 0x65, 0x6e, 0x74, 0x49, 0x64, 0x12, 0x1c, 0x0a, 0x09, 0x64, 0x69, 0x72, 0x65, 0x63, 0x74, + 0x69, 0x6f, 0x6e, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x64, 0x69, 0x72, 0x65, 0x63, + 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x21, 0x0a, 0x0c, 0x61, 0x6d, 0x6f, 0x75, 0x6e, 0x74, 0x5f, 0x63, + 0x6f, 0x69, 0x6e, 0x73, 0x18, 0x06, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0b, 0x61, 0x6d, 0x6f, 0x75, + 0x6e, 0x74, 0x43, 0x6f, 0x69, 0x6e, 0x73, 0x12, 0x16, 0x0a, 0x06, 0x72, 0x65, 0x61, 0x73, 0x6f, + 0x6e, 0x18, 0x07, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x72, 0x65, 0x61, 0x73, 0x6f, 0x6e, 0x12, + 0x2a, 0x0a, 0x11, 0x6f, 0x70, 0x65, 0x72, 0x61, 0x74, 0x6f, 0x72, 0x5f, 0x61, 0x64, 0x6d, 0x69, + 0x6e, 0x5f, 0x69, 0x64, 0x18, 0x08, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0f, 0x6f, 0x70, 0x65, 0x72, + 0x61, 0x74, 0x6f, 0x72, 0x41, 0x64, 0x6d, 0x69, 0x6e, 0x49, 0x64, 0x22, 0x92, 0x03, 0x0a, 0x17, + 0x4c, 0x75, 0x63, 0x6b, 0x79, 0x47, 0x69, 0x66, 0x74, 0x50, 0x6f, 0x6f, 0x6c, 0x41, 0x64, 0x6a, + 0x75, 0x73, 0x74, 0x6d, 0x65, 0x6e, 0x74, 0x12, 0x23, 0x0a, 0x0d, 0x61, 0x64, 0x6a, 0x75, 0x73, + 0x74, 0x6d, 0x65, 0x6e, 0x74, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0c, + 0x61, 0x64, 0x6a, 0x75, 0x73, 0x74, 0x6d, 0x65, 0x6e, 0x74, 0x49, 0x64, 0x12, 0x19, 0x0a, 0x08, + 0x61, 0x70, 0x70, 0x5f, 0x63, 0x6f, 0x64, 0x65, 0x18, 0x02, 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, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x70, 0x6f, 0x6f, 0x6c, 0x49, 0x64, + 0x12, 0x29, 0x0a, 0x10, 0x73, 0x74, 0x72, 0x61, 0x74, 0x65, 0x67, 0x79, 0x5f, 0x76, 0x65, 0x72, + 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0f, 0x73, 0x74, 0x72, 0x61, + 0x74, 0x65, 0x67, 0x79, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x1c, 0x0a, 0x09, 0x64, + 0x69, 0x72, 0x65, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, + 0x64, 0x69, 0x72, 0x65, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x21, 0x0a, 0x0c, 0x61, 0x6d, 0x6f, + 0x75, 0x6e, 0x74, 0x5f, 0x63, 0x6f, 0x69, 0x6e, 0x73, 0x18, 0x06, 0x20, 0x01, 0x28, 0x03, 0x52, + 0x0b, 0x61, 0x6d, 0x6f, 0x75, 0x6e, 0x74, 0x43, 0x6f, 0x69, 0x6e, 0x73, 0x12, 0x16, 0x0a, 0x06, + 0x72, 0x65, 0x61, 0x73, 0x6f, 0x6e, 0x18, 0x07, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x72, 0x65, + 0x61, 0x73, 0x6f, 0x6e, 0x12, 0x2a, 0x0a, 0x11, 0x6f, 0x70, 0x65, 0x72, 0x61, 0x74, 0x6f, 0x72, + 0x5f, 0x61, 0x64, 0x6d, 0x69, 0x6e, 0x5f, 0x69, 0x64, 0x18, 0x08, 0x20, 0x01, 0x28, 0x03, 0x52, + 0x0f, 0x6f, 0x70, 0x65, 0x72, 0x61, 0x74, 0x6f, 0x72, 0x41, 0x64, 0x6d, 0x69, 0x6e, 0x49, 0x64, + 0x12, 0x25, 0x0a, 0x0e, 0x62, 0x61, 0x6c, 0x61, 0x6e, 0x63, 0x65, 0x5f, 0x62, 0x65, 0x66, 0x6f, + 0x72, 0x65, 0x18, 0x09, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0d, 0x62, 0x61, 0x6c, 0x61, 0x6e, 0x63, + 0x65, 0x42, 0x65, 0x66, 0x6f, 0x72, 0x65, 0x12, 0x23, 0x0a, 0x0d, 0x62, 0x61, 0x6c, 0x61, 0x6e, + 0x63, 0x65, 0x5f, 0x61, 0x66, 0x74, 0x65, 0x72, 0x18, 0x0a, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0c, + 0x62, 0x61, 0x6c, 0x61, 0x6e, 0x63, 0x65, 0x41, 0x66, 0x74, 0x65, 0x72, 0x12, 0x22, 0x0a, 0x0d, + 0x63, 0x72, 0x65, 0x61, 0x74, 0x65, 0x64, 0x5f, 0x61, 0x74, 0x5f, 0x6d, 0x73, 0x18, 0x0b, 0x20, + 0x01, 0x28, 0x03, 0x52, 0x0b, 0x63, 0x72, 0x65, 0x61, 0x74, 0x65, 0x64, 0x41, 0x74, 0x4d, 0x73, + 0x22, 0xdc, 0x01, 0x0a, 0x22, 0x41, 0x64, 0x6a, 0x75, 0x73, 0x74, 0x4c, 0x75, 0x63, 0x6b, 0x79, + 0x47, 0x69, 0x66, 0x74, 0x50, 0x6f, 0x6f, 0x6c, 0x42, 0x61, 0x6c, 0x61, 0x6e, 0x63, 0x65, 0x52, + 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x4b, 0x0a, 0x0a, 0x61, 0x64, 0x6a, 0x75, 0x73, + 0x74, 0x6d, 0x65, 0x6e, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x2b, 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, 0x41, 0x64, + 0x6a, 0x75, 0x73, 0x74, 0x6d, 0x65, 0x6e, 0x74, 0x52, 0x0a, 0x61, 0x64, 0x6a, 0x75, 0x73, 0x74, + 0x6d, 0x65, 0x6e, 0x74, 0x12, 0x3c, 0x0a, 0x04, 0x70, 0x6f, 0x6f, 0x6c, 0x18, 0x02, 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, 0x50, 0x6f, 0x6f, 0x6c, 0x42, 0x61, 0x6c, 0x61, 0x6e, 0x63, 0x65, 0x52, 0x04, 0x70, 0x6f, + 0x6f, 0x6c, 0x12, 0x2b, 0x0a, 0x11, 0x69, 0x64, 0x65, 0x6d, 0x70, 0x6f, 0x74, 0x65, 0x6e, 0x74, + 0x5f, 0x72, 0x65, 0x70, 0x6c, 0x61, 0x79, 0x18, 0x03, 0x20, 0x01, 0x28, 0x08, 0x52, 0x10, 0x69, + 0x64, 0x65, 0x6d, 0x70, 0x6f, 0x74, 0x65, 0x6e, 0x74, 0x52, 0x65, 0x70, 0x6c, 0x61, 0x79, 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, 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, + 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, 0x98, 0x07, 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, 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, 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, + 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, 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, 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, 0x12, 0x8b, 0x01, 0x0a, 0x1a, 0x41, 0x64, 0x6a, 0x75, 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, + 0x6c, 0x61, 0x6e, 0x63, 0x65, 0x12, 0x35, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x6c, 0x75, + 0x63, 0x6b, 0x79, 0x67, 0x69, 0x66, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x41, 0x64, 0x6a, 0x75, 0x73, + 0x74, 0x4c, 0x75, 0x63, 0x6b, 0x79, 0x47, 0x69, 0x66, 0x74, 0x50, 0x6f, 0x6f, 0x6c, 0x42, 0x61, + 0x6c, 0x61, 0x6e, 0x63, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x36, 0x2e, 0x68, + 0x79, 0x61, 0x70, 0x70, 0x2e, 0x6c, 0x75, 0x63, 0x6b, 0x79, 0x67, 0x69, 0x66, 0x74, 0x2e, 0x76, + 0x31, 0x2e, 0x41, 0x64, 0x6a, 0x75, 0x73, 0x74, 0x4c, 0x75, 0x63, 0x6b, 0x79, 0x47, 0x69, 0x66, + 0x74, 0x50, 0x6f, 0x6f, 0x6c, 0x42, 0x61, 0x6c, 0x61, 0x6e, 0x63, 0x65, 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, @@ -3352,37 +3753,40 @@ 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, 29) +var file_proto_luckygift_v1_luckygift_proto_msgTypes = make([]protoimpl.MessageInfo, 32) var file_proto_luckygift_v1_luckygift_proto_goTypes = []any{ - (*RequestMeta)(nil), // 0: hyapp.luckygift.v1.RequestMeta - (*LuckyGiftMeta)(nil), // 1: hyapp.luckygift.v1.LuckyGiftMeta - (*LuckyGiftRuleTier)(nil), // 2: hyapp.luckygift.v1.LuckyGiftRuleTier - (*LuckyGiftRuleStage)(nil), // 3: hyapp.luckygift.v1.LuckyGiftRuleStage - (*LuckyGiftRuleConfig)(nil), // 4: hyapp.luckygift.v1.LuckyGiftRuleConfig - (*CheckLuckyGiftRequest)(nil), // 5: hyapp.luckygift.v1.CheckLuckyGiftRequest - (*CheckLuckyGiftResponse)(nil), // 6: hyapp.luckygift.v1.CheckLuckyGiftResponse - (*LuckyGiftDrawResult)(nil), // 7: hyapp.luckygift.v1.LuckyGiftDrawResult - (*ExecuteLuckyGiftDrawRequest)(nil), // 8: hyapp.luckygift.v1.ExecuteLuckyGiftDrawRequest - (*ExecuteLuckyGiftDrawResponse)(nil), // 9: hyapp.luckygift.v1.ExecuteLuckyGiftDrawResponse - (*BatchExecuteLuckyGiftDrawRequest)(nil), // 10: hyapp.luckygift.v1.BatchExecuteLuckyGiftDrawRequest - (*BatchExecuteLuckyGiftDrawResponse)(nil), // 11: hyapp.luckygift.v1.BatchExecuteLuckyGiftDrawResponse - (*ExternalGiftDrawRequest)(nil), // 12: hyapp.luckygift.v1.ExternalGiftDrawRequest - (*ExternalGiftDrawResponse)(nil), // 13: hyapp.luckygift.v1.ExternalGiftDrawResponse - (*GetLuckyGiftConfigRequest)(nil), // 14: hyapp.luckygift.v1.GetLuckyGiftConfigRequest - (*GetLuckyGiftConfigResponse)(nil), // 15: hyapp.luckygift.v1.GetLuckyGiftConfigResponse - (*UpsertLuckyGiftConfigRequest)(nil), // 16: hyapp.luckygift.v1.UpsertLuckyGiftConfigRequest - (*UpsertLuckyGiftConfigResponse)(nil), // 17: hyapp.luckygift.v1.UpsertLuckyGiftConfigResponse - (*ListLuckyGiftConfigsRequest)(nil), // 18: hyapp.luckygift.v1.ListLuckyGiftConfigsRequest - (*ListLuckyGiftConfigsResponse)(nil), // 19: hyapp.luckygift.v1.ListLuckyGiftConfigsResponse - (*ListLuckyGiftDrawsRequest)(nil), // 20: hyapp.luckygift.v1.ListLuckyGiftDrawsRequest - (*ListLuckyGiftDrawsResponse)(nil), // 21: hyapp.luckygift.v1.ListLuckyGiftDrawsResponse - (*LuckyGiftDrawSummary)(nil), // 22: hyapp.luckygift.v1.LuckyGiftDrawSummary - (*LuckyGiftPoolBalance)(nil), // 23: hyapp.luckygift.v1.LuckyGiftPoolBalance - (*GetLuckyGiftDrawSummaryRequest)(nil), // 24: hyapp.luckygift.v1.GetLuckyGiftDrawSummaryRequest - (*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 + (*RequestMeta)(nil), // 0: hyapp.luckygift.v1.RequestMeta + (*LuckyGiftMeta)(nil), // 1: hyapp.luckygift.v1.LuckyGiftMeta + (*LuckyGiftRuleTier)(nil), // 2: hyapp.luckygift.v1.LuckyGiftRuleTier + (*LuckyGiftRuleStage)(nil), // 3: hyapp.luckygift.v1.LuckyGiftRuleStage + (*LuckyGiftRuleConfig)(nil), // 4: hyapp.luckygift.v1.LuckyGiftRuleConfig + (*CheckLuckyGiftRequest)(nil), // 5: hyapp.luckygift.v1.CheckLuckyGiftRequest + (*CheckLuckyGiftResponse)(nil), // 6: hyapp.luckygift.v1.CheckLuckyGiftResponse + (*LuckyGiftDrawResult)(nil), // 7: hyapp.luckygift.v1.LuckyGiftDrawResult + (*ExecuteLuckyGiftDrawRequest)(nil), // 8: hyapp.luckygift.v1.ExecuteLuckyGiftDrawRequest + (*ExecuteLuckyGiftDrawResponse)(nil), // 9: hyapp.luckygift.v1.ExecuteLuckyGiftDrawResponse + (*BatchExecuteLuckyGiftDrawRequest)(nil), // 10: hyapp.luckygift.v1.BatchExecuteLuckyGiftDrawRequest + (*BatchExecuteLuckyGiftDrawResponse)(nil), // 11: hyapp.luckygift.v1.BatchExecuteLuckyGiftDrawResponse + (*ExternalGiftDrawRequest)(nil), // 12: hyapp.luckygift.v1.ExternalGiftDrawRequest + (*ExternalGiftDrawResponse)(nil), // 13: hyapp.luckygift.v1.ExternalGiftDrawResponse + (*GetLuckyGiftConfigRequest)(nil), // 14: hyapp.luckygift.v1.GetLuckyGiftConfigRequest + (*GetLuckyGiftConfigResponse)(nil), // 15: hyapp.luckygift.v1.GetLuckyGiftConfigResponse + (*UpsertLuckyGiftConfigRequest)(nil), // 16: hyapp.luckygift.v1.UpsertLuckyGiftConfigRequest + (*UpsertLuckyGiftConfigResponse)(nil), // 17: hyapp.luckygift.v1.UpsertLuckyGiftConfigResponse + (*ListLuckyGiftConfigsRequest)(nil), // 18: hyapp.luckygift.v1.ListLuckyGiftConfigsRequest + (*ListLuckyGiftConfigsResponse)(nil), // 19: hyapp.luckygift.v1.ListLuckyGiftConfigsResponse + (*ListLuckyGiftDrawsRequest)(nil), // 20: hyapp.luckygift.v1.ListLuckyGiftDrawsRequest + (*ListLuckyGiftDrawsResponse)(nil), // 21: hyapp.luckygift.v1.ListLuckyGiftDrawsResponse + (*LuckyGiftDrawSummary)(nil), // 22: hyapp.luckygift.v1.LuckyGiftDrawSummary + (*LuckyGiftPoolBalance)(nil), // 23: hyapp.luckygift.v1.LuckyGiftPoolBalance + (*GetLuckyGiftDrawSummaryRequest)(nil), // 24: hyapp.luckygift.v1.GetLuckyGiftDrawSummaryRequest + (*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 + (*AdjustLuckyGiftPoolBalanceRequest)(nil), // 29: hyapp.luckygift.v1.AdjustLuckyGiftPoolBalanceRequest + (*LuckyGiftPoolAdjustment)(nil), // 30: hyapp.luckygift.v1.LuckyGiftPoolAdjustment + (*AdjustLuckyGiftPoolBalanceResponse)(nil), // 31: hyapp.luckygift.v1.AdjustLuckyGiftPoolBalanceResponse } var file_proto_luckygift_v1_luckygift_proto_depIdxs = []int32{ 0, // 0: hyapp.luckygift.v1.LuckyGiftMeta.meta:type_name -> hyapp.luckygift.v1.RequestMeta @@ -3408,31 +3812,36 @@ var file_proto_luckygift_v1_luckygift_proto_depIdxs = []int32{ 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 + 0, // 23: hyapp.luckygift.v1.AdjustLuckyGiftPoolBalanceRequest.meta:type_name -> hyapp.luckygift.v1.RequestMeta + 30, // 24: hyapp.luckygift.v1.AdjustLuckyGiftPoolBalanceResponse.adjustment:type_name -> hyapp.luckygift.v1.LuckyGiftPoolAdjustment + 23, // 25: hyapp.luckygift.v1.AdjustLuckyGiftPoolBalanceResponse.pool:type_name -> hyapp.luckygift.v1.LuckyGiftPoolBalance + 5, // 26: hyapp.luckygift.v1.LuckyGiftService.CheckLuckyGift:input_type -> hyapp.luckygift.v1.CheckLuckyGiftRequest + 8, // 27: hyapp.luckygift.v1.LuckyGiftService.ExecuteLuckyGiftDraw:input_type -> hyapp.luckygift.v1.ExecuteLuckyGiftDrawRequest + 10, // 28: hyapp.luckygift.v1.LuckyGiftService.BatchExecuteLuckyGiftDraw:input_type -> hyapp.luckygift.v1.BatchExecuteLuckyGiftDrawRequest + 12, // 29: hyapp.luckygift.v1.LuckyGiftService.ExecuteExternalGiftDraw:input_type -> hyapp.luckygift.v1.ExternalGiftDrawRequest + 14, // 30: hyapp.luckygift.v1.AdminLuckyGiftService.GetLuckyGiftConfig:input_type -> hyapp.luckygift.v1.GetLuckyGiftConfigRequest + 16, // 31: hyapp.luckygift.v1.AdminLuckyGiftService.UpsertLuckyGiftConfig:input_type -> hyapp.luckygift.v1.UpsertLuckyGiftConfigRequest + 18, // 32: hyapp.luckygift.v1.AdminLuckyGiftService.ListLuckyGiftConfigs:input_type -> hyapp.luckygift.v1.ListLuckyGiftConfigsRequest + 20, // 33: hyapp.luckygift.v1.AdminLuckyGiftService.ListLuckyGiftDraws:input_type -> hyapp.luckygift.v1.ListLuckyGiftDrawsRequest + 24, // 34: hyapp.luckygift.v1.AdminLuckyGiftService.GetLuckyGiftDrawSummary:input_type -> hyapp.luckygift.v1.GetLuckyGiftDrawSummaryRequest + 26, // 35: hyapp.luckygift.v1.AdminLuckyGiftService.ListLuckyGiftPoolBalances:input_type -> hyapp.luckygift.v1.ListLuckyGiftPoolBalancesRequest + 29, // 36: hyapp.luckygift.v1.AdminLuckyGiftService.AdjustLuckyGiftPoolBalance:input_type -> hyapp.luckygift.v1.AdjustLuckyGiftPoolBalanceRequest + 6, // 37: hyapp.luckygift.v1.LuckyGiftService.CheckLuckyGift:output_type -> hyapp.luckygift.v1.CheckLuckyGiftResponse + 9, // 38: hyapp.luckygift.v1.LuckyGiftService.ExecuteLuckyGiftDraw:output_type -> hyapp.luckygift.v1.ExecuteLuckyGiftDrawResponse + 11, // 39: hyapp.luckygift.v1.LuckyGiftService.BatchExecuteLuckyGiftDraw:output_type -> hyapp.luckygift.v1.BatchExecuteLuckyGiftDrawResponse + 13, // 40: hyapp.luckygift.v1.LuckyGiftService.ExecuteExternalGiftDraw:output_type -> hyapp.luckygift.v1.ExternalGiftDrawResponse + 15, // 41: hyapp.luckygift.v1.AdminLuckyGiftService.GetLuckyGiftConfig:output_type -> hyapp.luckygift.v1.GetLuckyGiftConfigResponse + 17, // 42: hyapp.luckygift.v1.AdminLuckyGiftService.UpsertLuckyGiftConfig:output_type -> hyapp.luckygift.v1.UpsertLuckyGiftConfigResponse + 19, // 43: hyapp.luckygift.v1.AdminLuckyGiftService.ListLuckyGiftConfigs:output_type -> hyapp.luckygift.v1.ListLuckyGiftConfigsResponse + 21, // 44: hyapp.luckygift.v1.AdminLuckyGiftService.ListLuckyGiftDraws:output_type -> hyapp.luckygift.v1.ListLuckyGiftDrawsResponse + 25, // 45: hyapp.luckygift.v1.AdminLuckyGiftService.GetLuckyGiftDrawSummary:output_type -> hyapp.luckygift.v1.GetLuckyGiftDrawSummaryResponse + 27, // 46: hyapp.luckygift.v1.AdminLuckyGiftService.ListLuckyGiftPoolBalances:output_type -> hyapp.luckygift.v1.ListLuckyGiftPoolBalancesResponse + 31, // 47: hyapp.luckygift.v1.AdminLuckyGiftService.AdjustLuckyGiftPoolBalance:output_type -> hyapp.luckygift.v1.AdjustLuckyGiftPoolBalanceResponse + 37, // [37:48] is the sub-list for method output_type + 26, // [26:37] is the sub-list for method input_type + 26, // [26:26] is the sub-list for extension type_name + 26, // [26:26] is the sub-list for extension extendee + 0, // [0:26] is the sub-list for field type_name } func init() { file_proto_luckygift_v1_luckygift_proto_init() } @@ -3446,7 +3855,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: 29, + NumMessages: 32, NumExtensions: 0, NumServices: 2, }, diff --git a/api/proto/luckygift/v1/luckygift.proto b/api/proto/luckygift/v1/luckygift.proto index faf20d45..1c2df5fe 100644 --- a/api/proto/luckygift/v1/luckygift.proto +++ b/api/proto/luckygift/v1/luckygift.proto @@ -269,6 +269,9 @@ message LuckyGiftPoolBalance { int64 total_out = 7; bool materialized = 8; int64 updated_at_ms = 9; + string strategy_version = 10; + int64 manual_credit_total = 11; + int64 manual_debit_total = 12; } message GetLuckyGiftDrawSummaryRequest { @@ -289,6 +292,7 @@ message GetLuckyGiftDrawSummaryResponse { message ListLuckyGiftPoolBalancesRequest { RequestMeta meta = 1; string pool_id = 2; + string strategy_version = 3; } message ListLuckyGiftPoolBalancesResponse { @@ -309,6 +313,7 @@ service AdminLuckyGiftService { rpc ListLuckyGiftDraws(ListLuckyGiftDrawsRequest) returns (ListLuckyGiftDrawsResponse); rpc GetLuckyGiftDrawSummary(GetLuckyGiftDrawSummaryRequest) returns (GetLuckyGiftDrawSummaryResponse); rpc ListLuckyGiftPoolBalances(ListLuckyGiftPoolBalancesRequest) returns (ListLuckyGiftPoolBalancesResponse); + rpc AdjustLuckyGiftPoolBalance(AdjustLuckyGiftPoolBalanceRequest) returns (AdjustLuckyGiftPoolBalanceResponse); } // LuckyGiftHit 只返回批量逐份开奖中实际中奖的精简位置,不复制全部审计明细。 @@ -320,3 +325,36 @@ message LuckyGiftHit { int64 multiplier_ppm = 4; int64 reward_coins = 5; } + +// 调账消息也只追加在文件尾,保持所有既有 protobuf message index 稳定。 +// adjustment_id 是业务幂等键;RequestMeta.request_id 只做链路追踪,不能替代资金幂等语义。 +message AdjustLuckyGiftPoolBalanceRequest { + RequestMeta meta = 1; + string pool_id = 2; + string strategy_version = 3; + string adjustment_id = 4; + string direction = 5; + int64 amount_coins = 6; + string reason = 7; + int64 operator_admin_id = 8; +} + +message LuckyGiftPoolAdjustment { + string adjustment_id = 1; + string app_code = 2; + string pool_id = 3; + string strategy_version = 4; + string direction = 5; + int64 amount_coins = 6; + string reason = 7; + int64 operator_admin_id = 8; + int64 balance_before = 9; + int64 balance_after = 10; + int64 created_at_ms = 11; +} + +message AdjustLuckyGiftPoolBalanceResponse { + LuckyGiftPoolAdjustment adjustment = 1; + LuckyGiftPoolBalance pool = 2; + bool idempotent_replay = 3; +} diff --git a/api/proto/luckygift/v1/luckygift_grpc.pb.go b/api/proto/luckygift/v1/luckygift_grpc.pb.go index 5c8afac6..7e666eca 100644 --- a/api/proto/luckygift/v1/luckygift_grpc.pb.go +++ b/api/proto/luckygift/v1/luckygift_grpc.pb.go @@ -1,7 +1,7 @@ // Code generated by protoc-gen-go-grpc. DO NOT EDIT. // versions: // - protoc-gen-go-grpc v1.6.2 -// - protoc v5.29.2 +// - protoc v7.35.0 // source: proto/luckygift/v1/luckygift.proto package luckygiftv1 @@ -235,12 +235,13 @@ var LuckyGiftService_ServiceDesc = grpc.ServiceDesc{ } const ( - AdminLuckyGiftService_GetLuckyGiftConfig_FullMethodName = "/hyapp.luckygift.v1.AdminLuckyGiftService/GetLuckyGiftConfig" - AdminLuckyGiftService_UpsertLuckyGiftConfig_FullMethodName = "/hyapp.luckygift.v1.AdminLuckyGiftService/UpsertLuckyGiftConfig" - AdminLuckyGiftService_ListLuckyGiftConfigs_FullMethodName = "/hyapp.luckygift.v1.AdminLuckyGiftService/ListLuckyGiftConfigs" - AdminLuckyGiftService_ListLuckyGiftDraws_FullMethodName = "/hyapp.luckygift.v1.AdminLuckyGiftService/ListLuckyGiftDraws" - AdminLuckyGiftService_GetLuckyGiftDrawSummary_FullMethodName = "/hyapp.luckygift.v1.AdminLuckyGiftService/GetLuckyGiftDrawSummary" - AdminLuckyGiftService_ListLuckyGiftPoolBalances_FullMethodName = "/hyapp.luckygift.v1.AdminLuckyGiftService/ListLuckyGiftPoolBalances" + AdminLuckyGiftService_GetLuckyGiftConfig_FullMethodName = "/hyapp.luckygift.v1.AdminLuckyGiftService/GetLuckyGiftConfig" + AdminLuckyGiftService_UpsertLuckyGiftConfig_FullMethodName = "/hyapp.luckygift.v1.AdminLuckyGiftService/UpsertLuckyGiftConfig" + AdminLuckyGiftService_ListLuckyGiftConfigs_FullMethodName = "/hyapp.luckygift.v1.AdminLuckyGiftService/ListLuckyGiftConfigs" + AdminLuckyGiftService_ListLuckyGiftDraws_FullMethodName = "/hyapp.luckygift.v1.AdminLuckyGiftService/ListLuckyGiftDraws" + AdminLuckyGiftService_GetLuckyGiftDrawSummary_FullMethodName = "/hyapp.luckygift.v1.AdminLuckyGiftService/GetLuckyGiftDrawSummary" + AdminLuckyGiftService_ListLuckyGiftPoolBalances_FullMethodName = "/hyapp.luckygift.v1.AdminLuckyGiftService/ListLuckyGiftPoolBalances" + AdminLuckyGiftService_AdjustLuckyGiftPoolBalance_FullMethodName = "/hyapp.luckygift.v1.AdminLuckyGiftService/AdjustLuckyGiftPoolBalance" ) // AdminLuckyGiftServiceClient is the client API for AdminLuckyGiftService service. @@ -253,6 +254,7 @@ type AdminLuckyGiftServiceClient interface { ListLuckyGiftDraws(ctx context.Context, in *ListLuckyGiftDrawsRequest, opts ...grpc.CallOption) (*ListLuckyGiftDrawsResponse, error) GetLuckyGiftDrawSummary(ctx context.Context, in *GetLuckyGiftDrawSummaryRequest, opts ...grpc.CallOption) (*GetLuckyGiftDrawSummaryResponse, error) ListLuckyGiftPoolBalances(ctx context.Context, in *ListLuckyGiftPoolBalancesRequest, opts ...grpc.CallOption) (*ListLuckyGiftPoolBalancesResponse, error) + AdjustLuckyGiftPoolBalance(ctx context.Context, in *AdjustLuckyGiftPoolBalanceRequest, opts ...grpc.CallOption) (*AdjustLuckyGiftPoolBalanceResponse, error) } type adminLuckyGiftServiceClient struct { @@ -323,6 +325,16 @@ func (c *adminLuckyGiftServiceClient) ListLuckyGiftPoolBalances(ctx context.Cont return out, nil } +func (c *adminLuckyGiftServiceClient) AdjustLuckyGiftPoolBalance(ctx context.Context, in *AdjustLuckyGiftPoolBalanceRequest, opts ...grpc.CallOption) (*AdjustLuckyGiftPoolBalanceResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(AdjustLuckyGiftPoolBalanceResponse) + err := c.cc.Invoke(ctx, AdminLuckyGiftService_AdjustLuckyGiftPoolBalance_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + // AdminLuckyGiftServiceServer is the server API for AdminLuckyGiftService service. // All implementations must embed UnimplementedAdminLuckyGiftServiceServer // for forward compatibility. @@ -333,6 +345,7 @@ type AdminLuckyGiftServiceServer interface { ListLuckyGiftDraws(context.Context, *ListLuckyGiftDrawsRequest) (*ListLuckyGiftDrawsResponse, error) GetLuckyGiftDrawSummary(context.Context, *GetLuckyGiftDrawSummaryRequest) (*GetLuckyGiftDrawSummaryResponse, error) ListLuckyGiftPoolBalances(context.Context, *ListLuckyGiftPoolBalancesRequest) (*ListLuckyGiftPoolBalancesResponse, error) + AdjustLuckyGiftPoolBalance(context.Context, *AdjustLuckyGiftPoolBalanceRequest) (*AdjustLuckyGiftPoolBalanceResponse, error) mustEmbedUnimplementedAdminLuckyGiftServiceServer() } @@ -361,6 +374,9 @@ func (UnimplementedAdminLuckyGiftServiceServer) GetLuckyGiftDrawSummary(context. func (UnimplementedAdminLuckyGiftServiceServer) ListLuckyGiftPoolBalances(context.Context, *ListLuckyGiftPoolBalancesRequest) (*ListLuckyGiftPoolBalancesResponse, error) { return nil, status.Error(codes.Unimplemented, "method ListLuckyGiftPoolBalances not implemented") } +func (UnimplementedAdminLuckyGiftServiceServer) AdjustLuckyGiftPoolBalance(context.Context, *AdjustLuckyGiftPoolBalanceRequest) (*AdjustLuckyGiftPoolBalanceResponse, error) { + return nil, status.Error(codes.Unimplemented, "method AdjustLuckyGiftPoolBalance not implemented") +} func (UnimplementedAdminLuckyGiftServiceServer) mustEmbedUnimplementedAdminLuckyGiftServiceServer() {} func (UnimplementedAdminLuckyGiftServiceServer) testEmbeddedByValue() {} @@ -490,6 +506,24 @@ func _AdminLuckyGiftService_ListLuckyGiftPoolBalances_Handler(srv interface{}, c return interceptor(ctx, in, info, handler) } +func _AdminLuckyGiftService_AdjustLuckyGiftPoolBalance_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(AdjustLuckyGiftPoolBalanceRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(AdminLuckyGiftServiceServer).AdjustLuckyGiftPoolBalance(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: AdminLuckyGiftService_AdjustLuckyGiftPoolBalance_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(AdminLuckyGiftServiceServer).AdjustLuckyGiftPoolBalance(ctx, req.(*AdjustLuckyGiftPoolBalanceRequest)) + } + return interceptor(ctx, in, info, handler) +} + // AdminLuckyGiftService_ServiceDesc is the grpc.ServiceDesc for AdminLuckyGiftService service. // It's only intended for direct use with grpc.RegisterService, // and not to be introspected or modified (even as a copy) @@ -521,6 +555,10 @@ var AdminLuckyGiftService_ServiceDesc = grpc.ServiceDesc{ MethodName: "ListLuckyGiftPoolBalances", Handler: _AdminLuckyGiftService_ListLuckyGiftPoolBalances_Handler, }, + { + MethodName: "AdjustLuckyGiftPoolBalance", + Handler: _AdminLuckyGiftService_AdjustLuckyGiftPoolBalance_Handler, + }, }, Streams: []grpc.StreamDesc{}, Metadata: "proto/luckygift/v1/luckygift.proto", diff --git a/server/admin/internal/integration/luckygiftadmin/client.go b/server/admin/internal/integration/luckygiftadmin/client.go index c66edebb..0643d936 100644 --- a/server/admin/internal/integration/luckygiftadmin/client.go +++ b/server/admin/internal/integration/luckygiftadmin/client.go @@ -17,6 +17,7 @@ type Client interface { ListLuckyGiftDraws(ctx context.Context, req *luckygiftv1.ListLuckyGiftDrawsRequest) (*luckygiftv1.ListLuckyGiftDrawsResponse, error) GetLuckyGiftDrawSummary(ctx context.Context, req *luckygiftv1.GetLuckyGiftDrawSummaryRequest) (*luckygiftv1.GetLuckyGiftDrawSummaryResponse, error) ListLuckyGiftPoolBalances(ctx context.Context, req *luckygiftv1.ListLuckyGiftPoolBalancesRequest) (*luckygiftv1.ListLuckyGiftPoolBalancesResponse, error) + AdjustLuckyGiftPoolBalance(ctx context.Context, req *luckygiftv1.AdjustLuckyGiftPoolBalanceRequest) (*luckygiftv1.AdjustLuckyGiftPoolBalanceResponse, error) } type GRPCClient struct { @@ -50,3 +51,7 @@ func (c *GRPCClient) GetLuckyGiftDrawSummary(ctx context.Context, req *luckygift func (c *GRPCClient) ListLuckyGiftPoolBalances(ctx context.Context, req *luckygiftv1.ListLuckyGiftPoolBalancesRequest) (*luckygiftv1.ListLuckyGiftPoolBalancesResponse, error) { return c.client.ListLuckyGiftPoolBalances(ctx, req) } + +func (c *GRPCClient) AdjustLuckyGiftPoolBalance(ctx context.Context, req *luckygiftv1.AdjustLuckyGiftPoolBalanceRequest) (*luckygiftv1.AdjustLuckyGiftPoolBalanceResponse, error) { + return c.client.AdjustLuckyGiftPoolBalance(ctx, req) +} diff --git a/server/admin/internal/modules/luckygift/handler.go b/server/admin/internal/modules/luckygift/handler.go index 44c16b49..387e331b 100644 --- a/server/admin/internal/modules/luckygift/handler.go +++ b/server/admin/internal/modules/luckygift/handler.go @@ -14,6 +14,8 @@ import ( luckygiftv1 "hyapp.local/api/proto/luckygift/v1" "github.com/gin-gonic/gin" + "google.golang.org/grpc/codes" + grpcstatus "google.golang.org/grpc/status" ) type Handler struct { @@ -128,15 +130,44 @@ type drawSummaryDTO struct { } type poolBalanceDTO struct { - AppCode string `json:"app_code"` - PoolID string `json:"pool_id"` - Balance int64 `json:"balance"` - ReserveFloor int64 `json:"reserve_floor"` - AvailableBalance int64 `json:"available_balance"` - TotalIn int64 `json:"total_in"` - TotalOut int64 `json:"total_out"` - Materialized bool `json:"materialized"` - UpdatedAtMS int64 `json:"updated_at_ms"` + AppCode string `json:"app_code"` + PoolID string `json:"pool_id"` + StrategyVersion string `json:"strategy_version"` + Balance int64 `json:"balance"` + ReserveFloor int64 `json:"reserve_floor"` + AvailableBalance int64 `json:"available_balance"` + TotalIn int64 `json:"total_in"` + TotalOut int64 `json:"total_out"` + ManualCreditTotal int64 `json:"manual_credit_total"` + ManualDebitTotal int64 `json:"manual_debit_total"` + Materialized bool `json:"materialized"` + UpdatedAtMS int64 `json:"updated_at_ms"` +} + +type poolAdjustmentRequest struct { + AdjustmentID string `json:"adjustment_id"` + AmountCoins int64 `json:"amount_coins"` + Reason string `json:"reason"` +} + +type poolAdjustmentDTO struct { + AdjustmentID string `json:"adjustment_id"` + AppCode string `json:"app_code"` + PoolID string `json:"pool_id"` + StrategyVersion string `json:"strategy_version"` + Direction string `json:"direction"` + AmountCoins int64 `json:"amount_coins"` + Reason string `json:"reason"` + OperatorAdminID int64 `json:"operator_admin_id"` + BalanceBefore int64 `json:"balance_before"` + BalanceAfter int64 `json:"balance_after"` + CreatedAtMS int64 `json:"created_at_ms"` +} + +type poolAdjustmentResponseDTO struct { + Adjustment poolAdjustmentDTO `json:"adjustment"` + Pool poolBalanceDTO `json:"pool"` + IdempotentReplay bool `json:"idempotent_replay"` } func (h *Handler) GetLuckyGiftConfig(c *gin.Context) { @@ -273,8 +304,9 @@ func (h *Handler) ListLuckyGiftPoolBalances(c *gin.Context) { ctx, cancel := h.luckyGiftContext(c) defer cancel() resp, err := h.luckyGift.ListLuckyGiftPoolBalances(ctx, &luckygiftv1.ListLuckyGiftPoolBalancesRequest{ - Meta: h.meta(c), - PoolId: strings.TrimSpace(c.Query("pool_id")), + Meta: h.meta(c), + PoolId: strings.TrimSpace(c.Query("pool_id")), + StrategyVersion: strings.ToLower(strings.TrimSpace(c.Query("strategy_version"))), }) if err != nil { response.ServerError(c, "获取幸运礼物奖池余额失败") @@ -287,6 +319,75 @@ func (h *Handler) ListLuckyGiftPoolBalances(c *gin.Context) { response.OK(c, gin.H{"items": items, "total": len(items)}) } +func (h *Handler) CreditLuckyGiftPoolBalance(c *gin.Context) { + h.adjustLuckyGiftPoolBalance(c, "in") +} + +func (h *Handler) DebitLuckyGiftPoolBalance(c *gin.Context) { + h.adjustLuckyGiftPoolBalance(c, "out") +} + +func (h *Handler) adjustLuckyGiftPoolBalance(c *gin.Context, direction string) { + appCode := strings.ToLower(strings.TrimSpace(c.Query("app_code"))) + poolID := strings.TrimSpace(c.Query("pool_id")) + strategyVersion := strings.ToLower(strings.TrimSpace(c.Query("strategy_version"))) + if appCode == "" || poolID == "" || (strategyVersion != "fixed_v2" && strategyVersion != "dynamic_v3") { + response.BadRequest(c, "app_code, pool_id and strategy_version are required") + return + } + var req poolAdjustmentRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "幸运礼物奖池调整参数不正确") + return + } + req.AdjustmentID = strings.TrimSpace(req.AdjustmentID) + req.Reason = strings.TrimSpace(req.Reason) + if req.AdjustmentID == "" || req.AmountCoins <= 0 || req.Reason == "" { + response.BadRequest(c, "adjustment_id, positive amount_coins and reason are required") + return + } + ctx, cancel := h.luckyGiftContext(c) + defer cancel() + resp, err := h.luckyGift.AdjustLuckyGiftPoolBalance(ctx, &luckygiftv1.AdjustLuckyGiftPoolBalanceRequest{ + Meta: h.metaForApp(c, appCode), + PoolId: poolID, + StrategyVersion: strategyVersion, + AdjustmentId: req.AdjustmentID, + Direction: direction, + AmountCoins: req.AmountCoins, + Reason: req.Reason, + OperatorAdminId: int64(middleware.CurrentUserID(c)), + }) + if err != nil { + h.writePoolAdjustmentError(c, err) + return + } + item := poolAdjustmentResponseDTO{ + Adjustment: poolAdjustmentFromProto(resp.GetAdjustment()), + Pool: poolBalanceFromProto(resp.GetPool()), + IdempotentReplay: resp.GetIdempotentReplay(), + } + shared.OperationLogWithResourceID( + c, h.audit, "adjust-lucky-gift-pool-"+direction, "lucky_pool_adjustments", item.Adjustment.AdjustmentID, "success", + fmt.Sprintf("app_code=%s pool_id=%s strategy_version=%s direction=%s amount_coins=%d reason=%s", appCode, poolID, strategyVersion, direction, req.AmountCoins, req.Reason), + ) + response.OK(c, item) +} + +func (h *Handler) writePoolAdjustmentError(c *gin.Context, err error) { + status := grpcstatus.Convert(err) + switch status.Code() { + case codes.InvalidArgument: + response.BadRequest(c, status.Message()) + case codes.NotFound: + response.NotFound(c, status.Message()) + case codes.AlreadyExists, codes.FailedPrecondition, codes.Aborted: + response.Conflict(c, status.Message()) + default: + response.ServerError(c, "调整幸运礼物奖池余额失败") + } +} + func (h *Handler) meta(c *gin.Context) *luckygiftv1.RequestMeta { return h.metaForApp(c, "") } @@ -311,6 +412,12 @@ func (h *Handler) luckyGiftContext(c *gin.Context) (context.Context, context.Can } func configToProto(req configRequest) *luckygiftv1.LuckyGiftRuleConfig { + strategyVersion := strings.ToLower(strings.TrimSpace(req.StrategyVersion)) + initialPoolCoins := req.InitialPoolCoins + if strategyVersion == "dynamic_v3" { + // V3 启动资金已经迁移为独立人工 credit;旧页面残留的 1,000,000 不能在再次保存时回写不可变配置。 + initialPoolCoins = 0 + } stages := make([]*luckygiftv1.LuckyGiftRuleStage, 0, len(req.Stages)) for _, stage := range req.Stages { stageName := strings.TrimSpace(stage.Stage) @@ -339,7 +446,7 @@ func configToProto(req configRequest) *luckygiftv1.LuckyGiftRuleConfig { return &luckygiftv1.LuckyGiftRuleConfig{ AppCode: strings.ToLower(strings.TrimSpace(req.AppCode)), PoolId: strings.TrimSpace(req.PoolID), - StrategyVersion: strings.ToLower(strings.TrimSpace(req.StrategyVersion)), + StrategyVersion: strategyVersion, Enabled: req.Enabled, TargetRtpPpm: percentToPPM(req.TargetRTPPercent), PoolRatePpm: percentToPPM(req.PoolRatePercent), @@ -351,7 +458,7 @@ func configToProto(req configRequest) *luckygiftv1.LuckyGiftRuleConfig { NoviceMaxEquivalentDraws: req.NoviceMaxEquivalentDraws, NormalMaxEquivalentDraws: req.NormalMaxEquivalentDraws, EffectiveFromMs: req.EffectiveFromMS, - InitialPoolCoins: req.InitialPoolCoins, + InitialPoolCoins: initialPoolCoins, LossStreakGuarantee: req.LossStreakGuarantee, LowWatermarkCoins: req.LowWatermarkCoins, LowWaterNonzeroFactorPpm: percentToPPM(req.LowWaterNonzeroFactorPercent), @@ -399,6 +506,12 @@ func configFromProto(config *luckygiftv1.LuckyGiftRuleConfig) configDTO { }) } poolID := strings.TrimSpace(config.GetPoolId()) + strategyVersion := strings.ToLower(strings.TrimSpace(config.GetStrategyVersion())) + initialPoolCoins := config.GetInitialPoolCoins() + if strategyVersion == "dynamic_v3" { + // 兼容读取迁移前 V3 快照时也展示 0,避免运营误以为规则字段仍会自动形成账本资金。 + initialPoolCoins = 0 + } return configDTO{ RuleVersion: config.GetRuleVersion(), CreatedByAdminID: config.GetCreatedByAdminId(), @@ -406,7 +519,7 @@ func configFromProto(config *luckygiftv1.LuckyGiftRuleConfig) configDTO { configRequest: configRequest{ AppCode: config.GetAppCode(), PoolID: poolID, - StrategyVersion: config.GetStrategyVersion(), + StrategyVersion: strategyVersion, Enabled: config.GetEnabled(), TargetRTPPercent: ppmToPercent(config.GetTargetRtpPpm()), PoolRatePercent: ppmToPercent(config.GetPoolRatePpm()), @@ -418,7 +531,7 @@ func configFromProto(config *luckygiftv1.LuckyGiftRuleConfig) configDTO { NoviceMaxEquivalentDraws: config.GetNoviceMaxEquivalentDraws(), NormalMaxEquivalentDraws: config.GetNormalMaxEquivalentDraws(), EffectiveFromMS: config.GetEffectiveFromMs(), - InitialPoolCoins: config.GetInitialPoolCoins(), + InitialPoolCoins: initialPoolCoins, LossStreakGuarantee: config.GetLossStreakGuarantee(), LowWatermarkCoins: config.GetLowWatermarkCoins(), LowWaterNonzeroFactorPercent: ppmToPercent(config.GetLowWaterNonzeroFactorPpm()), @@ -551,15 +664,37 @@ func poolBalanceFromProto(pool *luckygiftv1.LuckyGiftPoolBalance) poolBalanceDTO return poolBalanceDTO{} } return poolBalanceDTO{ - AppCode: pool.GetAppCode(), - PoolID: pool.GetPoolId(), - Balance: pool.GetBalance(), - ReserveFloor: pool.GetReserveFloor(), - AvailableBalance: pool.GetAvailableBalance(), - TotalIn: pool.GetTotalIn(), - TotalOut: pool.GetTotalOut(), - Materialized: pool.GetMaterialized(), - UpdatedAtMS: pool.GetUpdatedAtMs(), + AppCode: pool.GetAppCode(), + PoolID: pool.GetPoolId(), + StrategyVersion: pool.GetStrategyVersion(), + Balance: pool.GetBalance(), + ReserveFloor: pool.GetReserveFloor(), + AvailableBalance: pool.GetAvailableBalance(), + TotalIn: pool.GetTotalIn(), + TotalOut: pool.GetTotalOut(), + ManualCreditTotal: pool.GetManualCreditTotal(), + ManualDebitTotal: pool.GetManualDebitTotal(), + Materialized: pool.GetMaterialized(), + UpdatedAtMS: pool.GetUpdatedAtMs(), + } +} + +func poolAdjustmentFromProto(adjustment *luckygiftv1.LuckyGiftPoolAdjustment) poolAdjustmentDTO { + if adjustment == nil { + return poolAdjustmentDTO{} + } + return poolAdjustmentDTO{ + AdjustmentID: adjustment.GetAdjustmentId(), + AppCode: adjustment.GetAppCode(), + PoolID: adjustment.GetPoolId(), + StrategyVersion: adjustment.GetStrategyVersion(), + Direction: adjustment.GetDirection(), + AmountCoins: adjustment.GetAmountCoins(), + Reason: adjustment.GetReason(), + OperatorAdminID: adjustment.GetOperatorAdminId(), + BalanceBefore: adjustment.GetBalanceBefore(), + BalanceAfter: adjustment.GetBalanceAfter(), + CreatedAtMS: adjustment.GetCreatedAtMs(), } } diff --git a/server/admin/internal/modules/luckygift/handler_test.go b/server/admin/internal/modules/luckygift/handler_test.go index c8ccf608..31d23623 100644 --- a/server/admin/internal/modules/luckygift/handler_test.go +++ b/server/admin/internal/modules/luckygift/handler_test.go @@ -1,11 +1,35 @@ package luckygift import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" "testing" + "time" + "hyapp-admin-server/internal/integration/luckygiftadmin" + "hyapp-admin-server/internal/middleware" luckygiftv1 "hyapp.local/api/proto/luckygift/v1" + + "github.com/gin-gonic/gin" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" ) +type poolAdjustmentClientStub struct { + luckygiftadmin.Client + request *luckygiftv1.AdjustLuckyGiftPoolBalanceRequest + response *luckygiftv1.AdjustLuckyGiftPoolBalanceResponse + err error +} + +func (s *poolAdjustmentClientStub) AdjustLuckyGiftPoolBalance(_ context.Context, request *luckygiftv1.AdjustLuckyGiftPoolBalanceRequest) (*luckygiftv1.AdjustLuckyGiftPoolBalanceResponse, error) { + s.request = request + return s.response, s.err +} + func TestConfigToProtoConvertsBusinessUnitsToPPM(t *testing.T) { config := configToProto(configRequest{ PoolID: " lucky_100 ", @@ -62,6 +86,63 @@ func TestConfigToProtoConvertsBusinessUnitsToPPM(t *testing.T) { } } +func TestCreditLuckyGiftPoolBalanceForwardsAuditedOwnerCommand(t *testing.T) { + gin.SetMode(gin.TestMode) + client := &poolAdjustmentClientStub{response: &luckygiftv1.AdjustLuckyGiftPoolBalanceResponse{ + Adjustment: &luckygiftv1.LuckyGiftPoolAdjustment{ + AdjustmentId: "adjustment-1", AppCode: "aslan", PoolId: "lucky", StrategyVersion: "dynamic_v3", + Direction: "in", AmountCoins: 100, Reason: "launch budget", OperatorAdminId: 7, BalanceBefore: 0, BalanceAfter: 100, + }, + Pool: &luckygiftv1.LuckyGiftPoolBalance{ + AppCode: "aslan", PoolId: "lucky", StrategyVersion: "dynamic_v3", Balance: 100, + ManualCreditTotal: 100, Materialized: true, + }, + }} + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + c.Request = httptest.NewRequest(http.MethodPost, "/admin/ops-center/lucky-gifts/pools/credit?app_code=aslan&pool_id=lucky&strategy_version=dynamic_v3", bytes.NewBufferString(`{"adjustment_id":"adjustment-1","amount_coins":100,"reason":"launch budget"}`)) + c.Request.Header.Set("Content-Type", "application/json") + c.Set(middleware.ContextUserID, uint(7)) + c.Set(middleware.ContextRequestID, "request-1") + New(client, time.Second, nil).CreditLuckyGiftPoolBalance(c) + if recorder.Code != http.StatusOK { + t.Fatalf("status=%d body=%s", recorder.Code, recorder.Body.String()) + } + if client.request == nil || client.request.GetDirection() != "in" || client.request.GetOperatorAdminId() != 7 || + client.request.GetMeta().GetAppCode() != "aslan" || client.request.GetMeta().GetRequestId() != "request-1" { + t.Fatalf("owner request=%+v", client.request) + } + var envelope struct { + Code int `json:"code"` + Data struct { + Pool struct { + StrategyVersion string `json:"strategy_version"` + ManualCreditTotal int64 `json:"manual_credit_total"` + } `json:"pool"` + } `json:"data"` + } + if err := json.Unmarshal(recorder.Body.Bytes(), &envelope); err != nil { + t.Fatalf("decode response: %v", err) + } + if envelope.Code != 0 || envelope.Data.Pool.StrategyVersion != "dynamic_v3" || envelope.Data.Pool.ManualCreditTotal != 100 { + t.Fatalf("response=%s", recorder.Body.String()) + } +} + +func TestDebitLuckyGiftPoolBalanceMapsOwnerConflictToHTTP409(t *testing.T) { + gin.SetMode(gin.TestMode) + client := &poolAdjustmentClientStub{err: status.Error(codes.FailedPrecondition, "debit would cross reserve floor")} + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + c.Request = httptest.NewRequest(http.MethodPost, "/admin/ops-center/lucky-gifts/pools/debit?app_code=aslan&pool_id=lucky&strategy_version=dynamic_v3", bytes.NewBufferString(`{"adjustment_id":"adjustment-2","amount_coins":101,"reason":"return budget"}`)) + c.Request.Header.Set("Content-Type", "application/json") + c.Set(middleware.ContextUserID, uint(7)) + New(client, time.Second, nil).DebitLuckyGiftPoolBalance(c) + if recorder.Code != http.StatusConflict { + t.Fatalf("status=%d want=409 body=%s", recorder.Code, recorder.Body.String()) + } +} + func TestConfigToProtoGeneratesUniqueTierIDs(t *testing.T) { config := configToProto(configRequest{ PoolID: "lucky", @@ -182,6 +263,9 @@ func TestDynamicConfigAdminMappingRoundTrip(t *testing.T) { if protoConfig.GetStrategyVersion() != "dynamic_v3" || protoConfig.GetProfitRatePpm() != 10_000 || protoConfig.GetAnchorRatePpm() != 10_000 { t.Fatalf("strategy/fund split proto mismatch: %+v", protoConfig) } + if protoConfig.GetInitialPoolCoins() != 0 { + t.Fatalf("dynamic_v3 legacy initial pool was forwarded: %d", protoConfig.GetInitialPoolCoins()) + } if protoConfig.GetLowWaterNonzeroFactorPpm() != 700_000 || protoConfig.GetHighWaterNonzeroFactorPpm() != 1_300_000 || protoConfig.GetRechargeBoostFactorPpm() != 1_100_000 { t.Fatalf("dynamic factors lost precision in admin mapping: %+v", protoConfig) } @@ -197,6 +281,9 @@ func TestDynamicConfigAdminMappingRoundTrip(t *testing.T) { if dto.StrategyVersion != "dynamic_v3" || dto.ProfitRatePercent != 1 || dto.AnchorRatePercent != 1 { t.Fatalf("strategy/fund split DTO mismatch: %+v", dto) } + if dto.InitialPoolCoins != 0 { + t.Fatalf("dynamic_v3 DTO exposed legacy initial pool: %d", dto.InitialPoolCoins) + } if dto.LowWaterNonzeroFactorPercent != 70 || dto.HighWaterNonzeroFactorPercent != 130 || dto.RechargeBoostFactorPercent != 110 { t.Fatalf("dynamic factor DTO mismatch: %+v", dto) } diff --git a/server/admin/internal/modules/opscenter/routes.go b/server/admin/internal/modules/opscenter/routes.go index 8f0433a7..4b7dfb7b 100644 --- a/server/admin/internal/modules/opscenter/routes.go +++ b/server/admin/internal/modules/opscenter/routes.go @@ -25,4 +25,6 @@ func RegisterRoutes(protected *gin.RouterGroup, h *Handler) { group.GET("/lucky-gifts/draws", middleware.RequirePermission("lucky-gift:view"), h.luckyGift.ListLuckyGiftDraws) group.GET("/lucky-gifts/summary", middleware.RequirePermission("lucky-gift:view"), h.luckyGift.GetLuckyGiftDrawSummary) group.GET("/lucky-gifts/pools", middleware.RequirePermission("lucky-gift:view"), h.luckyGift.ListLuckyGiftPoolBalances) + group.POST("/lucky-gifts/pools/credit", middleware.RequirePermission("lucky-gift:pool-credit"), h.luckyGift.CreditLuckyGiftPoolBalance) + group.POST("/lucky-gifts/pools/debit", middleware.RequirePermission("lucky-gift:pool-debit"), h.luckyGift.DebitLuckyGiftPoolBalance) } diff --git a/server/admin/internal/modules/opscenter/routes_test.go b/server/admin/internal/modules/opscenter/routes_test.go new file mode 100644 index 00000000..ba218421 --- /dev/null +++ b/server/admin/internal/modules/opscenter/routes_test.go @@ -0,0 +1,87 @@ +package opscenter + +import ( + "context" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "hyapp-admin-server/internal/integration/luckygiftadmin" + "hyapp-admin-server/internal/middleware" + luckygiftmodule "hyapp-admin-server/internal/modules/luckygift" + luckygiftv1 "hyapp.local/api/proto/luckygift/v1" + + "github.com/gin-gonic/gin" +) + +type poolPermissionClient struct { + luckygiftadmin.Client +} + +func (poolPermissionClient) AdjustLuckyGiftPoolBalance(_ context.Context, req *luckygiftv1.AdjustLuckyGiftPoolBalanceRequest) (*luckygiftv1.AdjustLuckyGiftPoolBalanceResponse, error) { + return &luckygiftv1.AdjustLuckyGiftPoolBalanceResponse{ + Adjustment: &luckygiftv1.LuckyGiftPoolAdjustment{ + AdjustmentId: req.GetAdjustmentId(), AppCode: req.GetMeta().GetAppCode(), PoolId: req.GetPoolId(), + StrategyVersion: req.GetStrategyVersion(), Direction: req.GetDirection(), AmountCoins: req.GetAmountCoins(), + }, + Pool: &luckygiftv1.LuckyGiftPoolBalance{ + AppCode: req.GetMeta().GetAppCode(), PoolId: req.GetPoolId(), StrategyVersion: req.GetStrategyVersion(), Materialized: true, + }, + }, nil +} + +func TestRegisterRoutesExposesStrategyPoolAdjustmentEndpoints(t *testing.T) { + gin.SetMode(gin.TestMode) + engine := gin.New() + RegisterRoutes(engine.Group("/api/v1"), New(nil, luckygiftmodule.New(poolPermissionClient{}, 0, nil))) + routes := make(map[string]struct{}) + for _, route := range engine.Routes() { + routes[route.Method+" "+route.Path] = struct{}{} + } + for _, expected := range []string{ + "POST /api/v1/admin/ops-center/lucky-gifts/pools/credit", + "POST /api/v1/admin/ops-center/lucky-gifts/pools/debit", + } { + if _, ok := routes[expected]; !ok { + t.Fatalf("missing route %s; actual=%v", expected, routes) + } + } +} + +func TestPoolAdjustmentRoutesRequireDirectionSpecificPermission(t *testing.T) { + gin.SetMode(gin.TestMode) + tests := []struct { + name string + path string + permission string + wantStatus int + }{ + {name: "credit allowed", path: "credit", permission: "lucky-gift:pool-credit", wantStatus: http.StatusOK}, + {name: "credit rejects debit permission", path: "credit", permission: "lucky-gift:pool-debit", wantStatus: http.StatusForbidden}, + {name: "debit allowed", path: "debit", permission: "lucky-gift:pool-debit", wantStatus: http.StatusOK}, + {name: "debit rejects view permission", path: "debit", permission: "lucky-gift:view", wantStatus: http.StatusForbidden}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + engine := gin.New() + engine.Use(func(c *gin.Context) { + c.Set(middleware.ContextPermissions, []string{tt.permission}) + c.Set(middleware.ContextUserID, uint(7)) + c.Next() + }) + RegisterRoutes(engine.Group("/api/v1"), New(nil, luckygiftmodule.New(poolPermissionClient{}, 0, nil))) + recorder := httptest.NewRecorder() + request := httptest.NewRequest( + http.MethodPost, + "/api/v1/admin/ops-center/lucky-gifts/pools/"+tt.path+"?app_code=aslan&pool_id=lucky&strategy_version=dynamic_v3", + strings.NewReader(`{"adjustment_id":"route-adjustment","amount_coins":1,"reason":"permission test"}`), + ) + request.Header.Set("Content-Type", "application/json") + engine.ServeHTTP(recorder, request) + if recorder.Code != tt.wantStatus { + t.Fatalf("status=%d want=%d body=%s", recorder.Code, tt.wantStatus, recorder.Body.String()) + } + }) + } +} diff --git a/server/admin/internal/repository/role_permission_matrix.go b/server/admin/internal/repository/role_permission_matrix.go index 8b7f8e02..2c4a1a72 100644 --- a/server/admin/internal/repository/role_permission_matrix.go +++ b/server/admin/internal/repository/role_permission_matrix.go @@ -129,7 +129,7 @@ var ( "daily-task:view", "first-recharge-reward:view", "cumulative-recharge-reward:view", "invite-activity-reward:view", "seven-day-checkin:view", "room-rocket:view", "room-turnover-reward:view", "wheel:view", "weekly-star:view", "agency-opening:view", "user-leaderboard:view", "red-packet:view", - "cp-config:view", "cp-weekly-rank:view", "vip-config:view", "achievement:view", + "cp-config:view", "cp-weekly-rank:view", "vip-config:view", "achievement:view", "lucky-gift:view", } activityManage = []string{ "activity-template:create", "activity-template:update", "activity-template:publish", "activity-template:delete", @@ -140,8 +140,10 @@ var ( "wheel:update", "weekly-star:create", "weekly-star:update", "weekly-star:settle", "agency-opening:create", "agency-opening:update", "red-packet:update", "cp-config:update", "cp-weekly-rank:update", "vip-config:update", - "achievement:create", "achievement:update", + "achievement:create", "achievement:update", "lucky-gift:update", } + // 奖池人工增减属于资金高风险动作,只授予运营负责人;platform-admin 仍由全权限同步自动获得。 + luckyGiftPoolManage = []string{"lucky-gift:pool-credit", "lucky-gift:pool-debit"} gameRead = []string{ "game-catalog:view", "self-game:view", "game-robot:view", "room-rps-config:view", "room-rps-order:view", @@ -172,7 +174,7 @@ func defaultRolePermissionCodes(code string) []string { roomRead, roomManage, []string{"app-config:view"}, appConfigManage, resourceRead, resourceManage, - operationsRead, operationsLeadManage, + operationsRead, operationsLeadManage, luckyGiftPoolManage, paymentRead, paymentBillExport, paymentBillRefresh, paymentOperationsManage, activityRead, activityManage, gameRead, gameManage, diff --git a/server/admin/internal/repository/seed.go b/server/admin/internal/repository/seed.go index 8c1385ab..42b430b6 100644 --- a/server/admin/internal/repository/seed.go +++ b/server/admin/internal/repository/seed.go @@ -197,6 +197,8 @@ var defaultPermissions = []model.Permission{ {Name: "七日签到更新", Code: "seven-day-checkin:update", Kind: "button"}, {Name: "幸运礼物查看", Code: "lucky-gift:view", Kind: "menu"}, {Name: "幸运礼物更新", Code: "lucky-gift:update", Kind: "button"}, + {Name: "幸运礼物奖池注资", Code: "lucky-gift:pool-credit", Kind: "button"}, + {Name: "幸运礼物奖池扣资", Code: "lucky-gift:pool-debit", Kind: "button"}, {Name: "转盘抽奖查看", Code: "wheel:view", Kind: "menu"}, {Name: "转盘抽奖更新", Code: "wheel:update", Kind: "button"}, {Name: "房间火箭查看", Code: "room-rocket:view", Kind: "menu"}, diff --git a/server/admin/internal/repository/seed_test.go b/server/admin/internal/repository/seed_test.go index a06e42ff..0be742ba 100644 --- a/server/admin/internal/repository/seed_test.go +++ b/server/admin/internal/repository/seed_test.go @@ -153,6 +153,21 @@ func TestManagedDefaultRoleModuleBoundaries(t *testing.T) { ) } +func TestLuckyGiftPermissionsStayInManagedRoleSync(t *testing.T) { + assertRolePermissions(t, roleCodeOperationsLead, + []string{"lucky-gift:view", "lucky-gift:update", "lucky-gift:pool-credit", "lucky-gift:pool-debit"}, nil, + ) + assertRolePermissions(t, roleCodeOperationsSpecialist, + []string{"lucky-gift:view"}, []string{"lucky-gift:update", "lucky-gift:pool-credit", "lucky-gift:pool-debit"}, + ) + assertRolePermissions(t, roleCodeProductLead, + []string{"lucky-gift:view", "lucky-gift:update"}, []string{"lucky-gift:pool-credit", "lucky-gift:pool-debit"}, + ) + assertRolePermissions(t, roleCodeProductSpecialist, + []string{"lucky-gift:view"}, []string{"lucky-gift:update", "lucky-gift:pool-credit", "lucky-gift:pool-debit"}, + ) +} + func TestRolePermissionMigrationMatchesManagedMatrix(t *testing.T) { content, err := os.ReadFile("../../migrations/093_role_permission_matrix.sql") if err != nil { @@ -221,6 +236,22 @@ func TestActivityTemplatePermissionMigrationExtendsManagedRoles(t *testing.T) { } } +func TestLuckyGiftPoolAdjustmentPermissionMigrationRepairsManagedRoles(t *testing.T) { + content, err := os.ReadFile("../../migrations/098_lucky_gift_pool_adjust_permissions.sql") + if err != nil { + t.Fatalf("read lucky gift pool permission migration: %v", err) + } + sqlText := string(content) + for _, token := range []string{ + "'lucky-gift:view'", "'lucky-gift:update'", "'lucky-gift:pool-credit'", "'lucky-gift:pool-debit'", + "'platform-admin', 'ops-admin'", "'operations-specialist'", "'product-lead'", "'product-specialist'", + } { + if !strings.Contains(sqlText, token) { + t.Fatalf("lucky gift pool permission migration missing %s", token) + } + } +} + func assertMigrationPermissionCodes(t *testing.T, roleCode string, sqlList string) { t.Helper() quotedCode := regexp.MustCompile(`'([a-z0-9:-]+)'`) @@ -236,6 +267,7 @@ func assertMigrationPermissionCodes(t *testing.T, roleCode string, sqlList strin "gift-record:view", "activity-template:view", "activity-template:create", "activity-template:update", "activity-template:publish", "activity-template:delete", "activity-template:data", "activity-template:export", "activity-template:retry", + "lucky-gift:view", "lucky-gift:update", "lucky-gift:pool-credit", "lucky-gift:pool-debit", }) sort.Strings(actual) sort.Strings(expected) diff --git a/server/admin/migrations/098_lucky_gift_pool_adjust_permissions.sql b/server/admin/migrations/098_lucky_gift_pool_adjust_permissions.sql new file mode 100644 index 00000000..53922998 --- /dev/null +++ b/server/admin/migrations/098_lucky_gift_pool_adjust_permissions.sql @@ -0,0 +1,34 @@ +-- 奖池人工增减是资金高风险动作,使用独立按钮权限;迁移仅按唯一 code 连接小型 RBAC 元数据表,不扫描业务流水。 +SET @now_ms = CAST(UNIX_TIMESTAMP(CURRENT_TIMESTAMP(3)) * 1000 AS UNSIGNED); + +INSERT INTO admin_permissions (name, code, kind, description, created_at_ms, updated_at_ms) VALUES + ('幸运礼物奖池注资', 'lucky-gift:pool-credit', 'button', '允许对指定策略奖池执行带幂等审计的人工注资', @now_ms, @now_ms), + ('幸运礼物奖池扣资', 'lucky-gift:pool-debit', 'button', '允许在安全水位之上执行带幂等审计的人工扣资', @now_ms, @now_ms) +ON DUPLICATE KEY UPDATE + name = VALUES(name), + kind = VALUES(kind), + description = VALUES(description), + updated_at_ms = @now_ms; + +-- 093 的岗位同步矩阵曾漏掉既有 lucky-gift:view/update;增量迁移按当前岗位边界补齐,避免下次同步再次丢失。 +INSERT IGNORE INTO admin_role_permissions (role_id, permission_id) +SELECT role.id, permission.id +FROM admin_roles role +JOIN admin_permissions permission +WHERE role.code IN ('platform-admin', 'ops-admin', 'operations-specialist', 'product-lead', 'product-specialist') + AND permission.code = 'lucky-gift:view'; + +INSERT IGNORE INTO admin_role_permissions (role_id, permission_id) +SELECT role.id, permission.id +FROM admin_roles role +JOIN admin_permissions permission +WHERE role.code IN ('platform-admin', 'ops-admin', 'product-lead') + AND permission.code = 'lucky-gift:update'; + +-- 实际资金操作只授予超级管理员和运营负责人;查看/规则编辑权限不能隐式获得调账能力。 +INSERT IGNORE INTO admin_role_permissions (role_id, permission_id) +SELECT role.id, permission.id +FROM admin_roles role +JOIN admin_permissions permission +WHERE role.code IN ('platform-admin', 'ops-admin') + AND permission.code IN ('lucky-gift:pool-credit', 'lucky-gift:pool-debit'); diff --git a/services/lucky-gift-service/deploy/mysql/initdb/001_lucky_gift_service.sql b/services/lucky-gift-service/deploy/mysql/initdb/001_lucky_gift_service.sql index 36227cb3..981ee29d 100644 --- a/services/lucky-gift-service/deploy/mysql/initdb/001_lucky_gift_service.sql +++ b/services/lucky-gift-service/deploy/mysql/initdb/001_lucky_gift_service.sql @@ -159,6 +159,8 @@ CREATE TABLE IF NOT EXISTS lucky_pools ( reserve_floor BIGINT NOT NULL DEFAULT 0 COMMENT '安全水位', total_in BIGINT NOT NULL DEFAULT 0 COMMENT '累计入池', total_out BIGINT NOT NULL DEFAULT 0 COMMENT '累计基础返奖支出', + manual_credit_total BIGINT NOT NULL DEFAULT 0 COMMENT '累计人工注资金币,不计入业务 total_in', + manual_debit_total BIGINT NOT NULL DEFAULT 0 COMMENT '累计人工扣资金币,不计入业务 total_out', profit_total BIGINT NOT NULL DEFAULT 0 COMMENT 'dynamic_v3 累计平台盈利拆账金币', anchor_income_total BIGINT NOT NULL DEFAULT 0 COMMENT 'dynamic_v3 累计主播收益拆账金币', initial_seed_total BIGINT NOT NULL DEFAULT 0 COMMENT 'dynamic_v3 累计初始注资金币,只在首次物化时增加', @@ -167,6 +169,28 @@ CREATE TABLE IF NOT EXISTS lucky_pools ( PRIMARY KEY (app_code, scope_type, scope_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='幸运礼物单一基础奖池'; +CREATE TABLE IF NOT EXISTS lucky_pool_adjustments ( + app_code VARCHAR(32) NOT NULL COMMENT '应用编码', + adjustment_id VARCHAR(128) NOT NULL COMMENT '调用方资金幂等 ID', + pool_id VARCHAR(96) NOT NULL COMMENT '幸运礼物奖池 ID', + strategy_version VARCHAR(32) NOT NULL COMMENT 'fixed_v2/dynamic_v3', + scope_type VARCHAR(32) NOT NULL COMMENT '实际资金账本 scope_type', + direction VARCHAR(16) NOT NULL COMMENT 'in/out', + amount_coins BIGINT NOT NULL COMMENT '本次人工调整金币', + reason VARCHAR(512) NOT NULL COMMENT '管理员填写的调整原因', + operator_admin_id BIGINT NOT NULL COMMENT '操作管理员 ID', + request_id VARCHAR(128) NOT NULL DEFAULT '' COMMENT '链路 request_id,不作为幂等键', + insert_token VARCHAR(64) NOT NULL COMMENT '首次插入事务随机令牌,用于并发幂等判定', + balance_before BIGINT NOT NULL DEFAULT 0 COMMENT '首次应用前余额', + balance_after BIGINT NOT NULL DEFAULT 0 COMMENT '首次应用后余额', + pool_snapshot_json JSON NULL COMMENT '首次应用后的完整奖池快照,供幂等重放', + status VARCHAR(16) NOT NULL DEFAULT 'pending' COMMENT 'pending/applied', + created_at_ms BIGINT NOT NULL COMMENT '创建时间,UTC epoch ms', + updated_at_ms BIGINT NOT NULL COMMENT '更新时间,UTC epoch ms', + PRIMARY KEY (app_code, adjustment_id), + KEY idx_lucky_pool_adjustments_pool (app_code, pool_id, strategy_version, created_at_ms) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='幸运礼物人工奖池调整事实'; + CREATE TABLE IF NOT EXISTS lucky_risk_counters ( app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码', scope_type VARCHAR(32) NOT NULL COMMENT 'user/device/room/anchor', diff --git a/services/lucky-gift-service/deploy/mysql/migrations/005_lucky_pool_balance.sql b/services/lucky-gift-service/deploy/mysql/migrations/005_lucky_pool_balance.sql new file mode 100644 index 00000000..8bdec345 --- /dev/null +++ b/services/lucky-gift-service/deploy/mysql/migrations/005_lucky_pool_balance.sql @@ -0,0 +1,61 @@ +SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci; + +USE hyapp_lucky_gift; + +-- 每列独立探测,使迁移在“全新库、004 后旧库、曾执行一半的旧库”上都可重复运行。 +-- 人工资金必须与正常送礼入池/返奖分账,不能污染 total_in/total_out 的 RTP 经营口径。 +SET @ddl := IF((SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'lucky_pools' AND COLUMN_NAME = 'manual_credit_total') = 0, + 'ALTER TABLE lucky_pools ADD COLUMN manual_credit_total BIGINT NOT NULL DEFAULT 0 COMMENT ''累计人工注资金币,不计入业务 total_in'' AFTER total_out', 'SELECT 1'); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @ddl := IF((SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'lucky_pools' AND COLUMN_NAME = 'manual_debit_total') = 0, + 'ALTER TABLE lucky_pools ADD COLUMN manual_debit_total BIGINT NOT NULL DEFAULT 0 COMMENT ''累计人工扣资金币,不计入业务 total_out'' AFTER manual_credit_total', 'SELECT 1'); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +CREATE TABLE IF NOT EXISTS lucky_pool_adjustments ( + app_code VARCHAR(32) NOT NULL COMMENT '应用编码', + adjustment_id VARCHAR(128) NOT NULL COMMENT '调用方资金幂等 ID', + pool_id VARCHAR(96) NOT NULL COMMENT '幸运礼物奖池 ID', + strategy_version VARCHAR(32) NOT NULL COMMENT 'fixed_v2/dynamic_v3', + scope_type VARCHAR(32) NOT NULL COMMENT '实际资金账本 scope_type', + direction VARCHAR(16) NOT NULL COMMENT 'in/out', + amount_coins BIGINT NOT NULL COMMENT '本次人工调整金币', + reason VARCHAR(512) NOT NULL COMMENT '管理员填写的调整原因', + operator_admin_id BIGINT NOT NULL COMMENT '操作管理员 ID', + request_id VARCHAR(128) NOT NULL DEFAULT '' COMMENT '链路 request_id,不作为幂等键', + insert_token VARCHAR(64) NOT NULL COMMENT '首次插入事务随机令牌,用于并发幂等判定', + balance_before BIGINT NOT NULL DEFAULT 0 COMMENT '首次应用前余额', + balance_after BIGINT NOT NULL DEFAULT 0 COMMENT '首次应用后余额', + pool_snapshot_json JSON NULL COMMENT '首次应用后的完整奖池快照,供幂等重放', + status VARCHAR(16) NOT NULL DEFAULT 'pending' COMMENT 'pending/applied', + created_at_ms BIGINT NOT NULL COMMENT '创建时间,UTC epoch ms', + updated_at_ms BIGINT NOT NULL COMMENT '更新时间,UTC epoch ms', + PRIMARY KEY (app_code, adjustment_id), + KEY idx_lucky_pool_adjustments_pool (app_code, pool_id, strategy_version, created_at_ms) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='幸运礼物人工奖池调整事实'; + +SET @ddl := IF((SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'lucky_pool_adjustments' AND COLUMN_NAME = 'insert_token') = 0, + 'ALTER TABLE lucky_pool_adjustments ADD COLUMN insert_token VARCHAR(64) NOT NULL DEFAULT '''' COMMENT ''首次插入事务随机令牌,用于并发幂等判定'' AFTER request_id', 'SELECT 1'); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +-- 只看每个 app+pool 当前最新不可变版本。若它已经切换到 dynamic_v3,就创建一口独立的零余额账本; +-- ON DUPLICATE 分支绝不更新余额、累计或时间,迁移重跑以及后续 V3 版本都不会重置真实资金。 +INSERT INTO lucky_pools ( + app_code, scope_type, scope_id, balance, reserve_floor, total_in, total_out, + manual_credit_total, manual_debit_total, profit_total, anchor_income_total, initial_seed_total, + created_at_ms, updated_at_ms +) +SELECT + current_rule.app_code, 'pool_dynamic_v3', current_rule.pool_id, 0, 0, 0, 0, + 0, 0, 0, 0, 0, current_rule.created_at_ms, current_rule.created_at_ms +FROM lucky_gift_rule_versions current_rule +INNER JOIN ( + SELECT app_code, pool_id, MAX(rule_version) AS rule_version + FROM lucky_gift_rule_versions + GROUP BY app_code, pool_id +) latest + ON latest.app_code = current_rule.app_code + AND latest.pool_id = current_rule.pool_id + AND latest.rule_version = current_rule.rule_version +WHERE current_rule.strategy_version = 'dynamic_v3' +ON DUPLICATE KEY UPDATE scope_id = lucky_pools.scope_id; diff --git a/services/lucky-gift-service/internal/domain/luckygift/lucky_gift.go b/services/lucky-gift-service/internal/domain/luckygift/lucky_gift.go index d4ea6a0f..3ed7825c 100644 --- a/services/lucky-gift-service/internal/domain/luckygift/lucky_gift.go +++ b/services/lucky-gift-service/internal/domain/luckygift/lucky_gift.go @@ -23,6 +23,9 @@ const ( StrategyFixedV2 = "fixed_v2" StrategyDynamicV3 = "dynamic_v3" + PoolAdjustmentIn = "in" + PoolAdjustmentOut = "out" + EventTypeLuckyGiftDrawn = "LuckyGiftDrawn" EventTypeLuckyGiftRewardSettlement = "LuckyGiftRewardSettlement" EventTypeExternalLuckyGiftDrawn = "ExternalLuckyGiftDrawn" @@ -251,20 +254,58 @@ type DrawSummary struct { // PoolBalance 是后台看到的 app+pool_id 单池账本快照。 // materialized=false 表示该池还没有真实 lucky_pools 行,展示的是规则默认初始水位,不能当成已经入库的资金流水。 type PoolBalance struct { - AppCode string - PoolID string - Balance int64 - ReserveFloor int64 - AvailableBalance int64 - TotalIn int64 - TotalOut int64 - Materialized bool - UpdatedAtMS int64 + AppCode string + PoolID string + StrategyVersion string + Balance int64 + ReserveFloor int64 + AvailableBalance int64 + TotalIn int64 + TotalOut int64 + ManualCreditTotal int64 + ManualDebitTotal int64 + Materialized bool + UpdatedAtMS int64 } type PoolBalanceQuery struct { - AppCode string - PoolID string + AppCode string + PoolID string + StrategyVersion string +} + +// PoolAdjustmentCommand 是人工注资/扣资的 owner 命令。AdjustmentID 由调用方生成并在 App 内唯一, +// 同一个 ID 的重试只能复用完全相同的资金参数,避免网络重试演变成重复记账。 +type PoolAdjustmentCommand struct { + AppCode string + PoolID string + StrategyVersion string + AdjustmentID string + Direction string + AmountCoins int64 + Reason string + OperatorAdminID int64 + RequestID string +} + +type PoolAdjustment struct { + AdjustmentID string + AppCode string + PoolID string + StrategyVersion string + Direction string + AmountCoins int64 + Reason string + OperatorAdminID int64 + BalanceBefore int64 + BalanceAfter int64 + CreatedAtMS int64 +} + +type PoolAdjustmentResult struct { + Adjustment PoolAdjustment + Pool PoolBalance + IdempotentReplay bool } type AdminConfigQuery struct { diff --git a/services/lucky-gift-service/internal/domain/luckygift/strategy.go b/services/lucky-gift-service/internal/domain/luckygift/strategy.go index 5de07d32..00faaeae 100644 --- a/services/lucky-gift-service/internal/domain/luckygift/strategy.go +++ b/services/lucky-gift-service/internal/domain/luckygift/strategy.go @@ -281,7 +281,7 @@ func DefaultLuckyGiftStrategyConfig() StrategyConfig { {ID: "1000x", MultiplierPPM: 1_000_000_000, BaseWeightPPM: 0, Jackpot: true, JackpotWeight: 1, Enabled: true}, }, PublicPoolRatePPM: 980_000, ProfitPoolRatePPM: 10_000, AnchorReturnRatePPM: 10_000, - ColdStartPoolCoins: 1_000_000, + ColdStartPoolCoins: 0, LowWaterThresholdCoins: 10_000_000, HighWaterThresholdCoins: 20_000_000, LowWaterFactorPPM: 700_000, HighWaterFactorPPM: 1_300_000, RechargeFactorPPM: 1_100_000, RechargeBoostStartMS: 0, RechargeBoostEndMS: 5 * 60 * 1000, diff --git a/services/lucky-gift-service/internal/service/luckygift/config.go b/services/lucky-gift-service/internal/service/luckygift/config.go index 78d8fa5d..450fcd1b 100644 --- a/services/lucky-gift-service/internal/service/luckygift/config.go +++ b/services/lucky-gift-service/internal/service/luckygift/config.go @@ -18,18 +18,19 @@ const ( func DefaultRuleConfig(appCode, poolID string) domain.RuleConfig { poolID = normalizePoolID(poolID) return domain.RuleConfig{ - AppCode: appCode, - PoolID: poolID, - StrategyVersion: domain.StrategyDynamicV3, - Enabled: false, - TargetRTPPPM: 980_000, - PoolRatePPM: 980_000, - ProfitRatePPM: 10_000, - AnchorRatePPM: 10_000, - SettlementWindowWager: 1_000_000, - ControlBandPPM: 30_000, - GiftPriceReference: 100, - InitialPoolCoins: 1_000_000, + AppCode: appCode, + PoolID: poolID, + StrategyVersion: domain.StrategyDynamicV3, + Enabled: false, + TargetRTPPPM: 980_000, + PoolRatePPM: 980_000, + ProfitRatePPM: 10_000, + AnchorRatePPM: 10_000, + SettlementWindowWager: 1_000_000, + ControlBandPPM: 30_000, + GiftPriceReference: 100, + // dynamic_v3 的账本从 0 开始,任何启动资金都必须经过带管理员和原因的人工注资事实。 + InitialPoolCoins: 0, LossStreakGuarantee: 5, LowWatermarkCoins: 10_000_000, LowWaterNonzeroFactorPPM: 700_000, @@ -177,8 +178,8 @@ func validateDynamicRuleConfig(config domain.RuleConfig, stages map[string]domai if config.ProfitRatePPM < 0 || config.AnchorRatePPM < 0 || config.PoolRatePPM+config.ProfitRatePPM+config.AnchorRatePPM != ppmScale { return xerr.New(xerr.InvalidArgument, "dynamic_v3 pool, profit and anchor rates must be non-negative and sum to 100%") } - if config.InitialPoolCoins <= 0 { - return xerr.New(xerr.InvalidArgument, "dynamic_v3 initial pool coins must be positive") + if config.InitialPoolCoins != 0 { + return xerr.New(xerr.InvalidArgument, "dynamic_v3 initial pool coins must be zero; fund the pool through an audited credit adjustment") } if config.LossStreakGuarantee <= 0 { return xerr.New(xerr.InvalidArgument, "dynamic_v3 loss streak guarantee must be positive") diff --git a/services/lucky-gift-service/internal/service/luckygift/config_test.go b/services/lucky-gift-service/internal/service/luckygift/config_test.go index 9af086fe..3152e0b2 100644 --- a/services/lucky-gift-service/internal/service/luckygift/config_test.go +++ b/services/lucky-gift-service/internal/service/luckygift/config_test.go @@ -15,7 +15,7 @@ func TestDefaultRuleConfigUsesSafeDynamicV3Draft(t *testing.T) { if config.TargetRTPPPM != 980_000 || config.PoolRatePPM != 980_000 || config.ProfitRatePPM != 10_000 || config.AnchorRatePPM != 10_000 { t.Fatalf("default RTP/fund split mismatch: %+v", config) } - if config.ControlBandPPM != 30_000 || config.InitialPoolCoins != 1_000_000 || config.LossStreakGuarantee != 5 { + if config.ControlBandPPM != 30_000 || config.InitialPoolCoins != 0 || config.LossStreakGuarantee != 5 { t.Fatalf("default control values mismatch: %+v", config) } if config.LowWatermarkCoins != 10_000_000 || config.LowWaterNonzeroFactorPPM != 700_000 || @@ -53,6 +53,14 @@ func TestDefaultRuleConfigUsesSafeDynamicV3Draft(t *testing.T) { } } +func TestValidateDynamicV3RejectsImplicitInitialFunding(t *testing.T) { + config := DefaultRuleConfig("lalu", "lucky") + config.InitialPoolCoins = 1_000_000 + if err := validateRuleConfig(config); err == nil || !strings.Contains(err.Error(), "audited credit adjustment") { + t.Fatalf("implicit V3 seed error=%v, want audited credit guidance", err) + } +} + func TestValidateDynamicV3RequiresExplicitMonetaryRiskLimitsWhenEnabled(t *testing.T) { config := DefaultRuleConfig("lalu", "lucky") config.Enabled = true diff --git a/services/lucky-gift-service/internal/service/luckygift/pool_adjustment_test.go b/services/lucky-gift-service/internal/service/luckygift/pool_adjustment_test.go new file mode 100644 index 00000000..a70d7204 --- /dev/null +++ b/services/lucky-gift-service/internal/service/luckygift/pool_adjustment_test.go @@ -0,0 +1,71 @@ +package luckygift + +import ( + "context" + "testing" + "time" + + "hyapp/pkg/appcode" + "hyapp/pkg/xerr" + domain "hyapp/services/lucky-gift-service/internal/domain/luckygift" +) + +type poolAdjustmentRepositoryStub struct { + Repository + command domain.PoolAdjustmentCommand + nowMS int64 + result domain.PoolAdjustmentResult + err error + calls int +} + +func (s *poolAdjustmentRepositoryStub) AdjustLuckyGiftPoolBalance(_ context.Context, command domain.PoolAdjustmentCommand, nowMS int64) (domain.PoolAdjustmentResult, error) { + s.calls++ + s.command = command + s.nowMS = nowMS + return s.result, s.err +} + +func TestAdjustPoolBalanceNormalizesAndForwardsOwnerCommand(t *testing.T) { + stub := &poolAdjustmentRepositoryStub{result: domain.PoolAdjustmentResult{Pool: domain.PoolBalance{Balance: 50}}} + service := New(stub) + fixedNow := time.Date(2026, 7, 15, 1, 2, 3, 0, time.UTC) + service.SetClock(func() time.Time { return fixedNow }) + ctx := appcode.WithContext(context.Background(), " AsLaN ") + result, err := service.AdjustPoolBalance(ctx, domain.PoolAdjustmentCommand{ + PoolID: " lucky ", StrategyVersion: " DYNAMIC_V3 ", AdjustmentID: " adjustment-1 ", + Direction: " IN ", AmountCoins: 50, Reason: " approved budget ", OperatorAdminID: 9, + RequestID: " request-1 ", + }) + if err != nil { + t.Fatalf("adjust pool balance: %v", err) + } + if result.Pool.Balance != 50 || stub.calls != 1 { + t.Fatalf("result=%+v calls=%d", result, stub.calls) + } + if stub.command.AppCode != "aslan" || stub.command.PoolID != "lucky" || stub.command.StrategyVersion != domain.StrategyDynamicV3 || + stub.command.Direction != domain.PoolAdjustmentIn || stub.command.Reason != "approved budget" || stub.nowMS != fixedNow.UnixMilli() { + t.Fatalf("normalized command=%+v now=%d", stub.command, stub.nowMS) + } +} + +func TestAdjustPoolBalanceRejectsInvalidMoneyCommandBeforeRepository(t *testing.T) { + stub := &poolAdjustmentRepositoryStub{} + service := New(stub) + tests := []domain.PoolAdjustmentCommand{ + {StrategyVersion: domain.StrategyDynamicV3, AdjustmentID: "a", Direction: "in", AmountCoins: 1, Reason: "r", OperatorAdminID: 1}, + {PoolID: "lucky", StrategyVersion: "v4", AdjustmentID: "a", Direction: "in", AmountCoins: 1, Reason: "r", OperatorAdminID: 1}, + {PoolID: "lucky", StrategyVersion: domain.StrategyDynamicV3, Direction: "in", AmountCoins: 1, Reason: "r", OperatorAdminID: 1}, + {PoolID: "lucky", StrategyVersion: domain.StrategyDynamicV3, AdjustmentID: "a", Direction: "sideways", AmountCoins: 1, Reason: "r", OperatorAdminID: 1}, + {PoolID: "lucky", StrategyVersion: domain.StrategyDynamicV3, AdjustmentID: "a", Direction: "out", AmountCoins: 0, Reason: "r", OperatorAdminID: 1}, + {PoolID: "lucky", StrategyVersion: domain.StrategyDynamicV3, AdjustmentID: "a", Direction: "out", AmountCoins: 1, OperatorAdminID: 1}, + } + for index, command := range tests { + if _, err := service.AdjustPoolBalance(context.Background(), command); !xerr.IsCode(err, xerr.InvalidArgument) { + t.Fatalf("case %d error=%v, want invalid argument", index, err) + } + } + if stub.calls != 0 { + t.Fatalf("repository calls=%d, want 0 for invalid commands", stub.calls) + } +} diff --git a/services/lucky-gift-service/internal/service/luckygift/service.go b/services/lucky-gift-service/internal/service/luckygift/service.go index 988f7e30..e4c7e3be 100644 --- a/services/lucky-gift-service/internal/service/luckygift/service.go +++ b/services/lucky-gift-service/internal/service/luckygift/service.go @@ -10,6 +10,7 @@ import ( "strings" "sync" "time" + "unicode/utf8" "hyapp/pkg/appcode" "hyapp/pkg/giftlimits" @@ -37,6 +38,7 @@ type Repository interface { ListLuckyGiftDraws(ctx context.Context, query domain.DrawQuery) ([]domain.DrawResult, int64, error) GetLuckyGiftDrawSummary(ctx context.Context, query domain.DrawQuery) (domain.DrawSummary, error) ListLuckyGiftPoolBalances(ctx context.Context, query domain.PoolBalanceQuery) ([]domain.PoolBalance, error) + AdjustLuckyGiftPoolBalance(ctx context.Context, command domain.PoolAdjustmentCommand, nowMS int64) (domain.PoolAdjustmentResult, error) GetLuckyGiftDrawRewardState(ctx context.Context, appCode string, drawIDs []string) (domain.DrawRewardState, error) ClaimPendingLuckyGiftOutbox(ctx context.Context, workerID string, nowMS int64, lockTTL time.Duration, batchSize int) ([]domain.DrawOutbox, error) DeleteDeliveredLuckyGiftOutboxBefore(ctx context.Context, cutoffMS int64, limit int) (int64, error) @@ -780,9 +782,57 @@ func (s *Service) ListPoolBalances(ctx context.Context, query domain.PoolBalance if strings.TrimSpace(query.PoolID) != "" { query.PoolID = normalizePoolID(query.PoolID) } + query.StrategyVersion = strings.ToLower(strings.TrimSpace(query.StrategyVersion)) + if query.StrategyVersion != "" && query.StrategyVersion != domain.StrategyFixedV2 && query.StrategyVersion != domain.StrategyDynamicV3 { + return nil, xerr.New(xerr.InvalidArgument, "unsupported lucky gift strategy version") + } return s.repository.ListLuckyGiftPoolBalances(ctx, query) } +// AdjustPoolBalance 校验管理员资金命令并交给 owner 仓储执行。request_id 只记录链路, +// adjustment_id 才是可跨超时重试的业务幂等键。 +func (s *Service) AdjustPoolBalance(ctx context.Context, command domain.PoolAdjustmentCommand) (domain.PoolAdjustmentResult, error) { + if err := s.requireRepository(); err != nil { + return domain.PoolAdjustmentResult{}, err + } + command.AppCode = appcode.FromContext(ctx) + rawPoolID := strings.TrimSpace(command.PoolID) + if rawPoolID == "" { + return domain.PoolAdjustmentResult{}, xerr.New(xerr.InvalidArgument, "lucky gift pool adjustment pool_id is required") + } + command.PoolID = normalizePoolID(rawPoolID) + command.StrategyVersion = strings.ToLower(strings.TrimSpace(command.StrategyVersion)) + command.AdjustmentID = strings.TrimSpace(command.AdjustmentID) + command.Direction = strings.ToLower(strings.TrimSpace(command.Direction)) + command.Reason = strings.TrimSpace(command.Reason) + command.RequestID = strings.TrimSpace(command.RequestID) + if utf8.RuneCountInString(command.AppCode) > 32 || utf8.RuneCountInString(command.PoolID) > 96 { + return domain.PoolAdjustmentResult{}, xerr.New(xerr.InvalidArgument, "lucky gift pool adjustment app_code or pool_id is too long") + } + if command.StrategyVersion != domain.StrategyFixedV2 && command.StrategyVersion != domain.StrategyDynamicV3 { + return domain.PoolAdjustmentResult{}, xerr.New(xerr.InvalidArgument, "lucky gift pool strategy_version must be fixed_v2 or dynamic_v3") + } + if command.AdjustmentID == "" || utf8.RuneCountInString(command.AdjustmentID) > 128 { + return domain.PoolAdjustmentResult{}, xerr.New(xerr.InvalidArgument, "lucky gift pool adjustment_id is required and must not exceed 128 characters") + } + if command.Direction != domain.PoolAdjustmentIn && command.Direction != domain.PoolAdjustmentOut { + return domain.PoolAdjustmentResult{}, xerr.New(xerr.InvalidArgument, "lucky gift pool adjustment direction must be in or out") + } + if command.AmountCoins <= 0 { + return domain.PoolAdjustmentResult{}, xerr.New(xerr.InvalidArgument, "lucky gift pool adjustment amount_coins must be positive") + } + if command.Reason == "" || utf8.RuneCountInString(command.Reason) > 512 { + return domain.PoolAdjustmentResult{}, xerr.New(xerr.InvalidArgument, "lucky gift pool adjustment reason is required and must not exceed 512 characters") + } + if command.OperatorAdminID <= 0 { + return domain.PoolAdjustmentResult{}, xerr.New(xerr.InvalidArgument, "lucky gift pool adjustment operator_admin_id must be positive") + } + if utf8.RuneCountInString(command.RequestID) > 128 { + return domain.PoolAdjustmentResult{}, xerr.New(xerr.InvalidArgument, "lucky gift pool adjustment request_id must not exceed 128 characters") + } + return s.repository.AdjustLuckyGiftPoolBalance(ctx, command, s.now().UTC().UnixMilli()) +} + func (s *Service) requireRepository() error { if s == nil || s.repository == nil { return xerr.New(xerr.Unavailable, "lucky gift repository is not configured") diff --git a/services/lucky-gift-service/internal/storage/mysql/lucky_gift_dynamic_repository.go b/services/lucky-gift-service/internal/storage/mysql/lucky_gift_dynamic_repository.go index 0014b7ca..8cbe6b54 100644 --- a/services/lucky-gift-service/internal/storage/mysql/lucky_gift_dynamic_repository.go +++ b/services/lucky-gift-service/internal/storage/mysql/lucky_gift_dynamic_repository.go @@ -83,7 +83,8 @@ func (r *Repository) executeDynamicLuckyGiftDrawBatch(ctx context.Context, tx *s if err != nil { return nil, err } - basePool, err := r.getOrCreateLuckyPool(ctx, tx, appCode, luckyBasePoolScopeType, luckyBasePoolScopeID(config), config.InitialPoolCoins, 0, nowMS) + // dynamic_v3 永远从独立的零余额账本启动;运营启动资金必须走人工 credit,不能由规则版本发布隐式注资。 + basePool, err := r.getOrCreateLuckyPool(ctx, tx, appCode, luckyDynamicPoolScopeType, luckyBasePoolScopeID(config), 0, 0, nowMS) if err != nil { return nil, err } diff --git a/services/lucky-gift-service/internal/storage/mysql/lucky_gift_dynamic_repository_test.go b/services/lucky-gift-service/internal/storage/mysql/lucky_gift_dynamic_repository_test.go index 4918d59c..8d373b30 100644 --- a/services/lucky-gift-service/internal/storage/mysql/lucky_gift_dynamic_repository_test.go +++ b/services/lucky-gift-service/internal/storage/mysql/lucky_gift_dynamic_repository_test.go @@ -32,6 +32,14 @@ func TestDynamicLuckyGiftMySQLBatchAndExternalPaths(t *testing.T) { if _, err := repo.PublishLuckyGiftRuleConfig(ctx, internalRule, now-1_000); err != nil { t.Fatalf("publish internal dynamic rule: %v", err) } + // 同名 fixed_v2 旧账本模拟策略切换前历史水位;dynamic draw 必须只碰 pool_dynamic_v3。 + if _, err := schema.DB.Exec(` + INSERT INTO lucky_pools ( + app_code, scope_type, scope_id, balance, reserve_floor, total_in, total_out, + profit_total, anchor_income_total, initial_seed_total, created_at_ms, updated_at_ms + ) VALUES ('lalu', 'pool', ?, 4321, 321, 4000, 100, 0, 0, 4321, ?, ?)`, internalRule.PoolID, now-2_000, now-2_000); err != nil { + t.Fatalf("seed fixed strategy ledger: %v", err) + } checked, err := repo.CheckLuckyGift(ctx, domain.CheckCommand{PoolID: internalRule.PoolID, GiftID: "super-lucky", UserID: 1001, RoomID: "room-1"}) if err != nil || checked.StrategyVersion != domain.StrategyDynamicV3 { t.Fatalf("check did not expose immutable strategy version: result=%+v err=%v", checked, err) @@ -103,6 +111,13 @@ func TestDynamicLuckyGiftMySQLBatchAndExternalPaths(t *testing.T) { t.Fatalf("internal command lock count=%d err=%v", internalLockCount, err) } assertDynamicMySQLBatchFacts(t, schema.DB, "lalu", internalRule.PoolID, command.UserID, command.CommandID, 99, 100, 98, 1, 1) + var fixedBalance, fixedTotalIn, fixedTotalOut int64 + if err := schema.DB.QueryRow(` + SELECT balance, total_in, total_out FROM lucky_pools + WHERE app_code='lalu' AND scope_type='pool' AND scope_id=?`, internalRule.PoolID, + ).Scan(&fixedBalance, &fixedTotalIn, &fixedTotalOut); err != nil || fixedBalance != 4321 || fixedTotalIn != 4000 || fixedTotalOut != 100 { + t.Fatalf("dynamic draw changed fixed ledger balance/in/out=%d/%d/%d err=%v", fixedBalance, fixedTotalIn, fixedTotalOut, err) + } assertDynamicRTPWindowRollover(t, schema.DB, internalRule.PoolID) assertExactRolling72HourBoundary(t, repo, schema.DB, ctx, internalRule.PoolID, now) @@ -301,8 +316,10 @@ func assertDynamicTokenSurvivesUTCDayBoundary(t *testing.T, repo *Repository, db t.Fatalf("earn milestone token: %v", err) } assertPendingTokenCount(t, db, rule.PoolID, userID, 1) - if _, err := db.Exec(`UPDATE lucky_pools SET balance=0 WHERE app_code='lalu' AND scope_type='pool' AND scope_id=?`, rule.PoolID); err != nil { + if result, err := db.Exec(`UPDATE lucky_pools SET balance=0 WHERE app_code='lalu' AND scope_type=? AND scope_id=?`, luckyDynamicPoolScopeType, rule.PoolID); err != nil { t.Fatalf("drain token test pool: %v", err) + } else if rows, _ := result.RowsAffected(); rows != 1 { + t.Fatalf("drain token test pool affected=%d, want 1", rows) } dayTwoMS := time.UnixMilli(dayOneMS).UTC().Add(24 * time.Hour).UnixMilli() retain := earn @@ -314,8 +331,10 @@ func assertDynamicTokenSurvivesUTCDayBoundary(t *testing.T, repo *Repository, db t.Fatalf("retain token across UTC day: %v", err) } assertPendingTokenCount(t, db, rule.PoolID, userID, 1) - if _, err := db.Exec(`UPDATE lucky_pools SET balance=1000000 WHERE app_code='lalu' AND scope_type='pool' AND scope_id=?`, rule.PoolID); err != nil { + if result, err := db.Exec(`UPDATE lucky_pools SET balance=1000000 WHERE app_code='lalu' AND scope_type=? AND scope_id=?`, luckyDynamicPoolScopeType, rule.PoolID); err != nil { t.Fatalf("refill token test pool: %v", err) + } else if rows, _ := result.RowsAffected(); rows != 1 { + t.Fatalf("refill token test pool affected=%d, want 1", rows) } consume := retain consume.CommandID = "token-consume-next-day" @@ -466,7 +485,7 @@ func dynamicMySQLTestRule(poolID string) domain.RuleConfig { PoolID: poolID, StrategyVersion: domain.StrategyDynamicV3, Enabled: true, TargetRTPPPM: 980_000, PoolRatePPM: 980_000, ProfitRatePPM: 10_000, AnchorRatePPM: 10_000, SettlementWindowWager: 1_000_000, ControlBandPPM: 30_000, GiftPriceReference: 1, - InitialPoolCoins: 1_000_000, LossStreakGuarantee: 5, + InitialPoolCoins: 0, LossStreakGuarantee: 5, LowWatermarkCoins: 10_000_000, LowWaterNonzeroFactorPPM: 700_000, HighWatermarkCoins: 20_000_000, HighWaterNonzeroFactorPPM: 1_300_000, RechargeBoostWindowMS: 300_000, RechargeBoostFactorPPM: 1_100_000, diff --git a/services/lucky-gift-service/internal/storage/mysql/lucky_gift_dynamic_state.go b/services/lucky-gift-service/internal/storage/mysql/lucky_gift_dynamic_state.go index 089a1b99..79a1a900 100644 --- a/services/lucky-gift-service/internal/storage/mysql/lucky_gift_dynamic_state.go +++ b/services/lucky-gift-service/internal/storage/mysql/lucky_gift_dynamic_state.go @@ -272,7 +272,7 @@ func (r *Repository) updateLuckyDynamicPoolNet(ctx context.Context, tx *sql.Tx, profit_total = profit_total + ?, anchor_income_total = anchor_income_total + ?, updated_at_ms = ? WHERE app_code = ? AND scope_type = ? AND scope_id = ?`, publicIn, payout, publicIn, payout, profitIn, anchorIncome, nowMS, - appCode, luckyBasePoolScopeType, scopeID, + appCode, luckyDynamicPoolScopeType, scopeID, ) return err } diff --git a/services/lucky-gift-service/internal/storage/mysql/lucky_gift_repository.go b/services/lucky-gift-service/internal/storage/mysql/lucky_gift_repository.go index 7596d57e..9bed2921 100644 --- a/services/lucky-gift-service/internal/storage/mysql/lucky_gift_repository.go +++ b/services/lucky-gift-service/internal/storage/mysql/lucky_gift_repository.go @@ -22,6 +22,9 @@ import ( const ( luckyPPMScale int64 = 1_000_000 luckyBasePoolScopeType = "pool" + // dynamic_v3 与 fixed_v2 的资金算法和启动规则不同,必须使用独立主键空间; + // 任何兼容读取都不能把两种策略重新折叠到同一口 pool 账本。 + luckyDynamicPoolScopeType = "pool_dynamic_v3" ) type luckyRTPWindow struct { @@ -47,12 +50,14 @@ func (w luckyRTPWindow) withCurrentWager(spent int64) luckyRTPWindow { } type luckyPool struct { - ScopeType string - ScopeID string - Balance int64 - ReserveFloor int64 - TotalIn int64 - TotalOut int64 + ScopeType string + ScopeID string + Balance int64 + ReserveFloor int64 + TotalIn int64 + TotalOut int64 + ManualCreditTotal int64 + ManualDebitTotal int64 } func (p luckyPool) capacity() int64 { @@ -1938,15 +1943,29 @@ func (r *Repository) ListLuckyGiftPoolBalances(ctx context.Context, query domain if r == nil || r.db == nil { return nil, xerr.New(xerr.Unavailable, "mysql repository is not configured") } - appCode := appcode.Normalize(query.AppCode) + // 空 app_code 是后台“全部应用”筛选,不能经过 Normalize 变成默认 lalu;只有明确值才参与租户条件。 + appCode := strings.ToLower(strings.TrimSpace(query.AppCode)) poolID := strings.TrimSpace(query.PoolID) if poolID != "" { poolID = luckyPoolID(poolID) } + strategyVersion := strings.ToLower(strings.TrimSpace(query.StrategyVersion)) - // 先读真实单池账本。这个查询不会 FOR UPDATE,也不会调用 getOrCreate,数据汇总刷新不能制造奖池行或改变余额。 - where := []string{"scope_type = ?"} - args := []any{luckyBasePoolScopeType} + // 先读两套真实账本。这个查询不会 FOR UPDATE,也不会调用 getOrCreate,后台列表不能制造奖池行或改变余额。 + // strategy_version 由内部 scope_type 显式映射;固定与动态绝不能再按 app+pool 折叠覆盖。 + where := make([]string, 0, 3) + args := make([]any, 0, 4) + if strategyVersion != "" { + scopeType, err := luckyPoolScopeForStrategy(strategyVersion) + if err != nil { + return nil, err + } + where = append(where, "scope_type = ?") + args = append(args, scopeType) + } else { + where = append(where, "scope_type IN (?, ?)") + args = append(args, luckyBasePoolScopeType, luckyDynamicPoolScopeType) + } if appCode != "" { where = append(where, "app_code = ?") args = append(args, appCode) @@ -1956,10 +1975,11 @@ func (r *Repository) ListLuckyGiftPoolBalances(ctx context.Context, query domain args = append(args, poolID) } rows, err := r.db.QueryContext(ctx, ` - SELECT app_code, scope_id, balance, reserve_floor, total_in, total_out, updated_at_ms + SELECT app_code, scope_type, scope_id, balance, reserve_floor, total_in, total_out, + manual_credit_total, manual_debit_total, updated_at_ms FROM lucky_pools WHERE `+strings.Join(where, " AND ")+` - ORDER BY app_code ASC, scope_id ASC`, args...) + ORDER BY app_code ASC, scope_id ASC, scope_type ASC`, args...) if err != nil { return nil, err } @@ -1968,12 +1988,21 @@ func (r *Repository) ListLuckyGiftPoolBalances(ctx context.Context, query domain byKey := make(map[string]domain.PoolBalance) for rows.Next() { var item domain.PoolBalance - if err := rows.Scan(&item.AppCode, &item.PoolID, &item.Balance, &item.ReserveFloor, &item.TotalIn, &item.TotalOut, &item.UpdatedAtMS); err != nil { + var scopeType string + if err := rows.Scan( + &item.AppCode, &scopeType, &item.PoolID, &item.Balance, &item.ReserveFloor, &item.TotalIn, &item.TotalOut, + &item.ManualCreditTotal, &item.ManualDebitTotal, &item.UpdatedAtMS, + ); err != nil { return nil, err } + mappedStrategy, ok := luckyStrategyForPoolScope(scopeType) + if !ok { + continue + } + item.StrategyVersion = mappedStrategy item.Materialized = true item.AvailableBalance = luckyAvailableBalance(item.Balance, item.ReserveFloor) - byKey[luckyPoolBalanceKey(item.AppCode, item.PoolID)] = item + byKey[luckyPoolBalanceKey(item.AppCode, item.PoolID, item.StrategyVersion)] = item } if err := rows.Err(); err != nil { return nil, err @@ -1988,7 +2017,14 @@ func (r *Repository) ListLuckyGiftPoolBalances(ctx context.Context, query domain if poolID != "" && luckyPoolID(config.PoolID) != poolID { continue } - key := luckyPoolBalanceKey(config.AppCode, config.PoolID) + configStrategy := strings.ToLower(strings.TrimSpace(config.StrategyVersion)) + if configStrategy == "" { + configStrategy = domain.StrategyFixedV2 + } + if strategyVersion != "" && configStrategy != strategyVersion { + continue + } + key := luckyPoolBalanceKey(config.AppCode, config.PoolID, configStrategy) if _, exists := byKey[key]; exists { continue } @@ -1996,12 +2032,20 @@ func (r *Repository) ListLuckyGiftPoolBalances(ctx context.Context, query domain if err != nil { return nil, err } + initialBalance := runtimeConfig.InitialBasePool + reserveFloor := runtimeConfig.BasePoolReserve + if configStrategy == domain.StrategyDynamicV3 { + // V3 规则即使来自迁移前旧数据,也只能展示独立零余额冷启动;配置里的历史 initial_pool_coins 不再形成资金。 + initialBalance = 0 + reserveFloor = 0 + } byKey[key] = domain.PoolBalance{ AppCode: appcode.Normalize(config.AppCode), PoolID: runtimeConfig.PoolID, - Balance: runtimeConfig.InitialBasePool, - ReserveFloor: runtimeConfig.BasePoolReserve, - AvailableBalance: luckyAvailableBalance(runtimeConfig.InitialBasePool, runtimeConfig.BasePoolReserve), + StrategyVersion: configStrategy, + Balance: initialBalance, + ReserveFloor: reserveFloor, + AvailableBalance: luckyAvailableBalance(initialBalance, reserveFloor), Materialized: false, UpdatedAtMS: config.CreatedAtMS, } @@ -2009,14 +2053,22 @@ func (r *Repository) ListLuckyGiftPoolBalances(ctx context.Context, query domain // 没有任何配置的新应用仍然需要在 ops-center 看到 default 池。这里用当前默认规则推导初始水位,但不写库。 if appCode != "" && (poolID == "" || poolID == domain.DefaultPoolID) { - key := luckyPoolBalanceKey(appCode, domain.DefaultPoolID) + defaultStrategy := strategyVersion + if defaultStrategy == "" { + defaultStrategy = domain.StrategyDynamicV3 + } + key := luckyPoolBalanceKey(appCode, domain.DefaultPoolID, defaultStrategy) if _, exists := byKey[key]; !exists { - const defaultReferencePrice = int64(100) - defaultBalance := defaultReferencePrice * (950 + 500 + 2_375) - defaultReserve := defaultReferencePrice * (100 + 10 + 100) + var defaultBalance, defaultReserve int64 + if defaultStrategy == domain.StrategyFixedV2 { + const defaultReferencePrice = int64(100) + defaultBalance = defaultReferencePrice * (950 + 500 + 2_375) + defaultReserve = defaultReferencePrice * (100 + 10 + 100) + } byKey[key] = domain.PoolBalance{ AppCode: appCode, PoolID: domain.DefaultPoolID, + StrategyVersion: defaultStrategy, Balance: defaultBalance, ReserveFloor: defaultReserve, AvailableBalance: luckyAvailableBalance(defaultBalance, defaultReserve), @@ -2033,7 +2085,10 @@ func (r *Repository) ListLuckyGiftPoolBalances(ctx context.Context, query domain if items[i].AppCode != items[j].AppCode { return items[i].AppCode < items[j].AppCode } - return items[i].PoolID < items[j].PoolID + if items[i].PoolID != items[j].PoolID { + return items[i].PoolID < items[j].PoolID + } + return items[i].StrategyVersion < items[j].StrategyVersion }) return items, nil } @@ -2077,8 +2132,8 @@ func luckyDrawSummaryCanUseStats(query domain.DrawQuery) bool { return query.UserID <= 0 && strings.TrimSpace(query.RoomID) == "" && strings.TrimSpace(query.Status) == "" } -func luckyPoolBalanceKey(appCode string, poolID string) string { - return appcode.Normalize(appCode) + "\x00" + luckyPoolID(poolID) +func luckyPoolBalanceKey(appCode string, poolID string, strategyVersion string) string { + return appcode.Normalize(appCode) + "\x00" + luckyPoolID(poolID) + "\x00" + strings.ToLower(strings.TrimSpace(strategyVersion)) } func luckyAvailableBalance(balance int64, reserveFloor int64) int64 { @@ -2297,8 +2352,9 @@ func luckyRuntimeConfigFromRuleConfig(ruleConfig domain.RuleConfig) (domain.Conf config.StrategyVersion = domain.StrategyFixedV2 } if config.StrategyVersion == domain.StrategyDynamicV3 { - // V3 的冷启动资金是绝对金币值且 P=W 可以支付到 0;旧版按礼物参考价推导的 reserve 不能继续套用。 - config.InitialBasePool = config.InitialPoolCoins + // V3 只能由人工 credit 形成启动资金;旧配置残留的 initial_pool_coins 既不能注入账本,也不能进入策略冷启动状态。 + config.InitialPoolCoins = 0 + config.InitialBasePool = 0 config.BasePoolReserve = 0 } config.MultiplierPPMs = luckyMultiplierPPMsFromTiers(config.Tiers, config.GiftPrice) @@ -2509,12 +2565,14 @@ func (r *Repository) getOrCreateLuckyPool(ctx context.Context, tx *sql.Tx, appCo } var pool luckyPool err := tx.QueryRowContext(ctx, ` - SELECT scope_type, scope_id, balance, reserve_floor, total_in, total_out + SELECT scope_type, scope_id, balance, reserve_floor, total_in, total_out, + manual_credit_total, manual_debit_total FROM lucky_pools WHERE app_code = ? AND scope_type = ? AND scope_id = ? FOR UPDATE`, appCode, scopeType, scopeID, - ).Scan(&pool.ScopeType, &pool.ScopeID, &pool.Balance, &pool.ReserveFloor, &pool.TotalIn, &pool.TotalOut) + ).Scan(&pool.ScopeType, &pool.ScopeID, &pool.Balance, &pool.ReserveFloor, &pool.TotalIn, &pool.TotalOut, + &pool.ManualCreditTotal, &pool.ManualDebitTotal) return pool, err } diff --git a/services/lucky-gift-service/internal/storage/mysql/lucky_gift_rule_config_repository.go b/services/lucky-gift-service/internal/storage/mysql/lucky_gift_rule_config_repository.go index 7c87f5e1..14c35178 100644 --- a/services/lucky-gift-service/internal/storage/mysql/lucky_gift_rule_config_repository.go +++ b/services/lucky-gift-service/internal/storage/mysql/lucky_gift_rule_config_repository.go @@ -45,6 +45,10 @@ func (r *Repository) PublishLuckyGiftRuleConfig(ctx context.Context, config doma // 升级前的发布请求没有 strategy_version;存储边界再次兜底为 fixed_v2,避免绕过 service 的调用写成动态规则。 config.StrategyVersion = domain.StrategyFixedV2 } + if config.StrategyVersion == domain.StrategyDynamicV3 && config.InitialPoolCoins != 0 { + // 仓储边界再次守住零冷启动,即使内部调用绕过 service 校验,也不能发布一个会暗示自动注资的 V3 快照。 + return domain.RuleConfig{}, xerr.New(xerr.InvalidArgument, "dynamic_v3 initial pool coins must be zero") + } if config.JackpotMultiplierPPMs == nil { config.JackpotMultiplierPPMs = []int64{} } @@ -121,6 +125,21 @@ func (r *Repository) PublishLuckyGiftRuleConfig(ctx context.Context, config doma } } } + if config.StrategyVersion == domain.StrategyDynamicV3 { + // 发布首个 V3 版本只物化一口 0 余额账本;后续 V3 版本命中唯一键后必须保持余额、累计和时间不变。 + // 这也保证 fixed_v2 -> dynamic_v3 切换不会继承旧 scope_type=pool 的历史资金。 + if _, err := tx.ExecContext(ctx, ` + INSERT INTO lucky_pools ( + app_code, scope_type, scope_id, balance, reserve_floor, total_in, total_out, + manual_credit_total, manual_debit_total, profit_total, anchor_income_total, initial_seed_total, + created_at_ms, updated_at_ms + ) VALUES (?, ?, ?, 0, 0, 0, 0, 0, 0, 0, 0, 0, ?, ?) + ON DUPLICATE KEY UPDATE scope_id = scope_id`, + config.AppCode, luckyDynamicPoolScopeType, config.PoolID, nowMS, nowMS, + ); err != nil { + return domain.RuleConfig{}, err + } + } if err := tx.Commit(); err != nil { return domain.RuleConfig{}, err } diff --git a/services/lucky-gift-service/internal/storage/mysql/lucky_gift_rule_config_repository_test.go b/services/lucky-gift-service/internal/storage/mysql/lucky_gift_rule_config_repository_test.go index 0cc239a4..c2d9f648 100644 --- a/services/lucky-gift-service/internal/storage/mysql/lucky_gift_rule_config_repository_test.go +++ b/services/lucky-gift-service/internal/storage/mysql/lucky_gift_rule_config_repository_test.go @@ -78,7 +78,7 @@ func TestPublishLuckyGiftRuleConfigPersistsVersionsAndTiers(t *testing.T) { dynamic.StrategyVersion = domain.StrategyDynamicV3 dynamic.ProfitRatePPM = 10_000 dynamic.AnchorRatePPM = 10_000 - dynamic.InitialPoolCoins = 1_000_000 + dynamic.InitialPoolCoins = 0 dynamic.LossStreakGuarantee = 5 dynamic.LowWatermarkCoins = 10_000_000 dynamic.LowWaterNonzeroFactorPPM = 700_000 @@ -112,7 +112,7 @@ func TestPublishLuckyGiftRuleConfigPersistsVersionsAndTiers(t *testing.T) { if err != nil || !exists { t.Fatalf("get dynamic rule: exists=%v err=%v", exists, err) } - if loaded.StrategyVersion != domain.StrategyDynamicV3 || loaded.InitialPoolCoins != 1_000_000 || loaded.AnchorDailyPayoutCap != 6_000_000 { + if loaded.StrategyVersion != domain.StrategyDynamicV3 || loaded.InitialPoolCoins != 0 || loaded.AnchorDailyPayoutCap != 6_000_000 { t.Fatalf("dynamic scalar fields were not persisted: %+v", loaded) } if len(loaded.JackpotMultiplierPPMs) != 3 || loaded.JackpotMultiplierPPMs[2] != 1_000_000_000 { @@ -121,6 +121,25 @@ func TestPublishLuckyGiftRuleConfigPersistsVersionsAndTiers(t *testing.T) { if len(loaded.Stages) != 1 || loaded.Stages[0].MinRecharge7DCoins != 1 || loaded.Stages[0].MinRecharge30DCoins != 2 { t.Fatalf("dynamic stage thresholds were not persisted: %+v", loaded.Stages) } + var dynamicBalance int64 + if err := schema.DB.QueryRow(` + SELECT balance FROM lucky_pools + WHERE app_code='lalu' AND scope_type='pool_dynamic_v3' AND scope_id='lucky'`).Scan(&dynamicBalance); err != nil || dynamicBalance != 0 { + t.Fatalf("first dynamic publish balance=%d err=%v, want materialized zero ledger", dynamicBalance, err) + } + if _, err := schema.DB.Exec(` + UPDATE lucky_pools SET balance=777, manual_credit_total=777 + WHERE app_code='lalu' AND scope_type='pool_dynamic_v3' AND scope_id='lucky'`); err != nil { + t.Fatalf("fund dynamic ledger before republish: %v", err) + } + if _, err := repo.PublishLuckyGiftRuleConfig(ctx, dynamic, 1_700_000_300_000); err != nil { + t.Fatalf("publish later dynamic version: %v", err) + } + if err := schema.DB.QueryRow(` + SELECT balance FROM lucky_pools + WHERE app_code='lalu' AND scope_type='pool_dynamic_v3' AND scope_id='lucky'`).Scan(&dynamicBalance); err != nil || dynamicBalance != 777 { + t.Fatalf("later dynamic publish balance=%d err=%v, want existing 777 preserved", dynamicBalance, err) + } } func TestDynamicStrategyMigrationIsIdempotent(t *testing.T) { @@ -185,3 +204,52 @@ func TestDynamicStrategyMigrationIsIdempotent(t *testing.T) { t.Fatalf("pre-dynamic row decoded as strategy=%q jackpot=%v, want fixed_v2 and empty jackpot", legacy.StrategyVersion, legacy.JackpotMultiplierPPMs) } } + +func TestLuckyPoolBalanceMigrationMaterializesLatestDynamicV3AtZeroWithoutReset(t *testing.T) { + caller := mysqlschema.CallerFile(t, 1) + schema := mysqlschema.New(t, mysqlschema.Config{ + EnvVar: "LUCKY_GIFT_SERVICE_MYSQL_TEST_DSN", + InitDBPath: mysqlschema.InitDBPath(t, caller, "..", "..", "..", "deploy", "mysql", "initdb", "001_lucky_gift_service.sql"), + DatabasePrefix: "hy_lucky_gift", + }) + repo := &Repository{db: schema.DB} + ctx := appcode.WithContext(context.Background(), "aslan") + rule := dynamicMySQLTestRule("migration-v3") + if _, err := repo.PublishLuckyGiftRuleConfig(ctx, rule, 1_700_001_000_000); err != nil { + t.Fatalf("publish dynamic config: %v", err) + } + // 模拟现网已经发布的旧 V3 快照:配置里仍残留 1,000,000,但 005 必须忽略它并创建零余额新 scope。 + if _, err := schema.DB.Exec(`UPDATE lucky_gift_rule_versions SET initial_pool_coins=1000000 WHERE app_code='aslan' AND pool_id='migration-v3'`); err != nil { + t.Fatalf("seed legacy dynamic initial_pool_coins: %v", err) + } + if _, err := schema.DB.Exec(` + INSERT INTO lucky_pools ( + app_code, scope_type, scope_id, balance, reserve_floor, total_in, total_out, + profit_total, anchor_income_total, initial_seed_total, created_at_ms, updated_at_ms + ) VALUES ('aslan', 'pool', 'migration-v3', 888, 88, 1000, 112, 0, 0, 888, 1, 2)`); err != nil { + t.Fatalf("seed legacy fixed scope: %v", err) + } + if _, err := schema.DB.Exec(`DELETE FROM lucky_pools WHERE app_code='aslan' AND scope_type='pool_dynamic_v3' AND scope_id='migration-v3'`); err != nil { + t.Fatalf("simulate pre-005 database: %v", err) + } + migrationPath := mysqlschema.InitDBPath(t, caller, "..", "..", "..", "deploy", "mysql", "migrations", "005_lucky_pool_balance.sql") + runLuckyGiftSQLScript(t, schema.DB, migrationPath) + var balance int64 + if err := schema.DB.QueryRow(`SELECT balance FROM lucky_pools WHERE app_code='aslan' AND scope_type='pool_dynamic_v3' AND scope_id='migration-v3'`).Scan(&balance); err != nil || balance != 0 { + t.Fatalf("005 materialized balance=%d err=%v, want 0", balance, err) + } + var legacyBalance int64 + if err := schema.DB.QueryRow(`SELECT balance FROM lucky_pools WHERE app_code='aslan' AND scope_type='pool' AND scope_id='migration-v3'`).Scan(&legacyBalance); err != nil || legacyBalance != 888 { + t.Fatalf("005 changed legacy fixed scope balance=%d err=%v, want 888", legacyBalance, err) + } + if _, err := schema.DB.Exec(`UPDATE lucky_pools SET balance=321, manual_credit_total=321 WHERE app_code='aslan' AND scope_type='pool_dynamic_v3' AND scope_id='migration-v3'`); err != nil { + t.Fatalf("fund migrated pool: %v", err) + } + runLuckyGiftSQLScript(t, schema.DB, migrationPath) + if err := schema.DB.QueryRow(`SELECT balance FROM lucky_pools WHERE app_code='aslan' AND scope_type='pool_dynamic_v3' AND scope_id='migration-v3'`).Scan(&balance); err != nil || balance != 321 { + t.Fatalf("rerun 005 balance=%d err=%v, want existing 321 preserved", balance, err) + } + if err := schema.DB.QueryRow(`SELECT balance FROM lucky_pools WHERE app_code='aslan' AND scope_type='pool' AND scope_id='migration-v3'`).Scan(&legacyBalance); err != nil || legacyBalance != 888 { + t.Fatalf("rerun 005 changed legacy fixed scope balance=%d err=%v, want 888", legacyBalance, err) + } +} diff --git a/services/lucky-gift-service/internal/storage/mysql/lucky_pool_adjustment_repository.go b/services/lucky-gift-service/internal/storage/mysql/lucky_pool_adjustment_repository.go new file mode 100644 index 00000000..2e9adefc --- /dev/null +++ b/services/lucky-gift-service/internal/storage/mysql/lucky_pool_adjustment_repository.go @@ -0,0 +1,261 @@ +package mysql + +import ( + "context" + "crypto/rand" + "database/sql" + "encoding/hex" + "encoding/json" + "errors" + "math" + "strings" + + "hyapp/pkg/appcode" + "hyapp/pkg/xerr" + domain "hyapp/services/lucky-gift-service/internal/domain/luckygift" +) + +func luckyPoolScopeForStrategy(strategyVersion string) (string, error) { + switch strings.ToLower(strings.TrimSpace(strategyVersion)) { + case domain.StrategyFixedV2: + return luckyBasePoolScopeType, nil + case domain.StrategyDynamicV3: + return luckyDynamicPoolScopeType, nil + default: + return "", xerr.New(xerr.InvalidArgument, "unsupported lucky gift strategy version") + } +} + +func luckyStrategyForPoolScope(scopeType string) (string, bool) { + switch scopeType { + case luckyBasePoolScopeType: + return domain.StrategyFixedV2, true + case luckyDynamicPoolScopeType: + return domain.StrategyDynamicV3, true + default: + return "", false + } +} + +// AdjustLuckyGiftPoolBalance 是人工资金调整的唯一持久化入口。同一个事务先锁 adjustment_id, +// 再锁 app+strategy+pool 资金行;这样并发重试只能重放首次结果,不能在两个事务中重复增减余额。 +func (r *Repository) AdjustLuckyGiftPoolBalance(ctx context.Context, command domain.PoolAdjustmentCommand, nowMS int64) (domain.PoolAdjustmentResult, error) { + if r == nil || r.db == nil { + return domain.PoolAdjustmentResult{}, xerr.New(xerr.Unavailable, "mysql repository is not configured") + } + appCode := appcode.Normalize(command.AppCode) + poolID := luckyPoolID(command.PoolID) + scopeType, err := luckyPoolScopeForStrategy(command.StrategyVersion) + if err != nil { + return domain.PoolAdjustmentResult{}, err + } + + tx, err := r.db.BeginTx(ctx, nil) + if err != nil { + return domain.PoolAdjustmentResult{}, err + } + defer func() { _ = tx.Rollback() }() + + var tokenEntropy [16]byte + if _, err := rand.Read(tokenEntropy[:]); err != nil { + return domain.PoolAdjustmentResult{}, xerr.New(xerr.Unavailable, "cannot generate lucky gift pool adjustment insert token") + } + insertToken := hex.EncodeToString(tokenEntropy[:]) + // 唯一键的 ON DUPLICATE 分支只做 no-op;随后比较库内首次随机令牌来判断本事务是否首写, + // 因而既不依赖 clientFoundRows,也避免多个 duplicate INSERT 错误事务互相持锁形成死锁。 + _, err = tx.ExecContext(ctx, ` + INSERT INTO lucky_pool_adjustments ( + app_code, adjustment_id, pool_id, strategy_version, scope_type, direction, + amount_coins, reason, operator_admin_id, request_id, insert_token, + balance_before, balance_after, pool_snapshot_json, status, created_at_ms, updated_at_ms + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, 0, NULL, 'pending', ?, ?) + ON DUPLICATE KEY UPDATE adjustment_id = adjustment_id`, + appCode, command.AdjustmentID, poolID, command.StrategyVersion, scopeType, command.Direction, + command.AmountCoins, command.Reason, command.OperatorAdminID, command.RequestID, insertToken, nowMS, nowMS, + ) + if err != nil { + return domain.PoolAdjustmentResult{}, err + } + + var stored domain.PoolAdjustment + var storedScopeType string + var storedRequestID string + var storedInsertToken string + var storedStatus string + var storedSnapshot sql.NullString + if err := tx.QueryRowContext(ctx, ` + SELECT adjustment_id, app_code, pool_id, strategy_version, scope_type, direction, + amount_coins, reason, operator_admin_id, request_id, insert_token, + balance_before, balance_after, pool_snapshot_json, status, created_at_ms + FROM lucky_pool_adjustments + WHERE app_code = ? AND adjustment_id = ? + FOR UPDATE`, appCode, command.AdjustmentID, + ).Scan( + &stored.AdjustmentID, &stored.AppCode, &stored.PoolID, &stored.StrategyVersion, &storedScopeType, &stored.Direction, + &stored.AmountCoins, &stored.Reason, &stored.OperatorAdminID, &storedRequestID, &storedInsertToken, + &stored.BalanceBefore, &stored.BalanceAfter, &storedSnapshot, &storedStatus, &stored.CreatedAtMS, + ); err != nil { + return domain.PoolAdjustmentResult{}, err + } + inserted := storedInsertToken == insertToken + + // request_id 允许随网络重试变化;其余字段共同定义资金命令,任何不一致都属于幂等冲突。 + if stored.PoolID != poolID || stored.StrategyVersion != command.StrategyVersion || storedScopeType != scopeType || + stored.Direction != command.Direction || stored.AmountCoins != command.AmountCoins || stored.Reason != command.Reason || + stored.OperatorAdminID != command.OperatorAdminID { + return domain.PoolAdjustmentResult{}, xerr.New(xerr.IdempotencyConflict, "lucky gift pool adjustment payload does not match the existing adjustment_id") + } + if !inserted { + if storedStatus != "applied" || !storedSnapshot.Valid { + // 正常事务不会留下 pending 行;若人工修复或异常导入造成该状态,宁可阻断也不能猜测是否已经动账。 + return domain.PoolAdjustmentResult{}, xerr.New(xerr.Conflict, "lucky gift pool adjustment is not in an applied state") + } + var snapshot domain.PoolBalance + if err := json.Unmarshal([]byte(storedSnapshot.String), &snapshot); err != nil { + return domain.PoolAdjustmentResult{}, xerr.New(xerr.Conflict, "lucky gift pool adjustment snapshot is invalid") + } + if err := tx.Commit(); err != nil { + return domain.PoolAdjustmentResult{}, err + } + return domain.PoolAdjustmentResult{Adjustment: stored, Pool: snapshot, IdempotentReplay: true}, nil + } + + pool, err := r.getOrCreateAdjustmentPool(ctx, tx, appCode, poolID, command.StrategyVersion, scopeType, nowMS) + if err != nil { + return domain.PoolAdjustmentResult{}, err + } + stored.BalanceBefore = pool.Balance + switch command.Direction { + case domain.PoolAdjustmentIn: + if pool.Balance > math.MaxInt64-command.AmountCoins || pool.ManualCreditTotal > math.MaxInt64-command.AmountCoins { + return domain.PoolAdjustmentResult{}, xerr.New(xerr.Conflict, "lucky gift pool credit would overflow the ledger") + } + pool.Balance += command.AmountCoins + pool.ManualCreditTotal += command.AmountCoins + case domain.PoolAdjustmentOut: + if pool.ManualDebitTotal > math.MaxInt64-command.AmountCoins || command.AmountCoins > pool.Balance || pool.Balance-command.AmountCoins < pool.ReserveFloor { + return domain.PoolAdjustmentResult{}, xerr.New(xerr.Conflict, "lucky gift pool debit would cross the reserve floor") + } + pool.Balance -= command.AmountCoins + pool.ManualDebitTotal += command.AmountCoins + default: + return domain.PoolAdjustmentResult{}, xerr.New(xerr.InvalidArgument, "lucky gift pool adjustment direction is invalid") + } + stored.BalanceAfter = pool.Balance + + // 手工资金只更新 balance 和独立累计列;业务 total_in/total_out 仍只表达送礼入池与实际返奖。 + updateResult, err := tx.ExecContext(ctx, ` + UPDATE lucky_pools + SET balance = ?, manual_credit_total = ?, manual_debit_total = ?, updated_at_ms = ? + WHERE app_code = ? AND scope_type = ? AND scope_id = ?`, + pool.Balance, pool.ManualCreditTotal, pool.ManualDebitTotal, nowMS, appCode, scopeType, poolID, + ) + if err != nil { + return domain.PoolAdjustmentResult{}, err + } + if rows, err := updateResult.RowsAffected(); err != nil { + return domain.PoolAdjustmentResult{}, err + } else if rows != 1 { + return domain.PoolAdjustmentResult{}, xerr.New(xerr.Conflict, "lucky gift pool changed during adjustment") + } + + snapshot := domain.PoolBalance{ + AppCode: appCode, + PoolID: poolID, + StrategyVersion: command.StrategyVersion, + Balance: pool.Balance, + ReserveFloor: pool.ReserveFloor, + AvailableBalance: luckyAvailableBalance(pool.Balance, pool.ReserveFloor), + TotalIn: pool.TotalIn, + TotalOut: pool.TotalOut, + ManualCreditTotal: pool.ManualCreditTotal, + ManualDebitTotal: pool.ManualDebitTotal, + Materialized: true, + UpdatedAtMS: nowMS, + } + snapshotJSON, err := json.Marshal(snapshot) + if err != nil { + return domain.PoolAdjustmentResult{}, err + } + finalizeResult, err := tx.ExecContext(ctx, ` + UPDATE lucky_pool_adjustments + SET balance_before = ?, balance_after = ?, pool_snapshot_json = ?, status = 'applied', updated_at_ms = ? + WHERE app_code = ? AND adjustment_id = ? AND status = 'pending'`, + stored.BalanceBefore, stored.BalanceAfter, snapshotJSON, nowMS, appCode, command.AdjustmentID, + ) + if err != nil { + return domain.PoolAdjustmentResult{}, err + } + if rows, err := finalizeResult.RowsAffected(); err != nil { + return domain.PoolAdjustmentResult{}, err + } else if rows != 1 { + return domain.PoolAdjustmentResult{}, xerr.New(xerr.Conflict, "lucky gift pool adjustment could not be finalized") + } + if err := tx.Commit(); err != nil { + return domain.PoolAdjustmentResult{}, err + } + return domain.PoolAdjustmentResult{Adjustment: stored, Pool: snapshot}, nil +} + +func (r *Repository) getOrCreateAdjustmentPool(ctx context.Context, tx *sql.Tx, appCode, poolID, strategyVersion, scopeType string, nowMS int64) (luckyPool, error) { + // 人工资金不能凭任意 pool_id 创建孤儿账本;指定策略至少要有一个已发布不可变版本。 + var configured int + err := tx.QueryRowContext(ctx, ` + SELECT 1 + FROM lucky_gift_rule_versions + WHERE app_code = ? AND pool_id = ? + AND COALESCE(NULLIF(strategy_version, ''), 'fixed_v2') = ? + LIMIT 1`, appCode, poolID, strategyVersion, + ).Scan(&configured) + if errors.Is(err, sql.ErrNoRows) { + return luckyPool{}, xerr.New(xerr.NotFound, "lucky gift pool strategy is not configured") + } + if err != nil { + return luckyPool{}, err + } + if strategyVersion == domain.StrategyDynamicV3 { + return r.getOrCreateLuckyPool(ctx, tx, appCode, scopeType, poolID, 0, 0, nowMS) + } + var existing luckyPool + err = tx.QueryRowContext(ctx, ` + SELECT scope_type, scope_id, balance, reserve_floor, total_in, total_out, + manual_credit_total, manual_debit_total + FROM lucky_pools + WHERE app_code = ? AND scope_type = ? AND scope_id = ? + FOR UPDATE`, appCode, scopeType, poolID, + ).Scan( + &existing.ScopeType, &existing.ScopeID, &existing.Balance, &existing.ReserveFloor, &existing.TotalIn, &existing.TotalOut, + &existing.ManualCreditTotal, &existing.ManualDebitTotal, + ) + if err == nil { + return existing, nil + } + if !errors.Is(err, sql.ErrNoRows) { + return luckyPool{}, err + } + + // fixed_v2 首次人工调整仍需保持旧算法的启动水位。只读取最新 fixed_v2 规则的参考价格, + // 不使用当前最新 dynamic_v3 配置,也不把旧固定账本从 0 重新解释。 + var referencePrice int64 + err = tx.QueryRowContext(ctx, ` + SELECT gift_price_reference + FROM lucky_gift_rule_versions + WHERE app_code = ? AND pool_id = ? + AND COALESCE(NULLIF(strategy_version, ''), 'fixed_v2') = 'fixed_v2' + ORDER BY rule_version DESC + LIMIT 1`, appCode, poolID, + ).Scan(&referencePrice) + if err != nil && !errors.Is(err, sql.ErrNoRows) { + return luckyPool{}, err + } + if errors.Is(err, sql.ErrNoRows) || referencePrice <= 0 { + return luckyPool{}, xerr.New(xerr.NotFound, "fixed_v2 lucky gift pool is not configured") + } + if referencePrice > math.MaxInt64/(950+500+2_375) { + return luckyPool{}, xerr.New(xerr.Conflict, "fixed_v2 lucky gift pool initial balance would overflow") + } + return r.getOrCreateLuckyPool( + ctx, tx, appCode, scopeType, poolID, + referencePrice*(950+500+2_375), referencePrice*(100+10+100), nowMS, + ) +} diff --git a/services/lucky-gift-service/internal/storage/mysql/lucky_pool_adjustment_repository_test.go b/services/lucky-gift-service/internal/storage/mysql/lucky_pool_adjustment_repository_test.go new file mode 100644 index 00000000..5bfe1345 --- /dev/null +++ b/services/lucky-gift-service/internal/storage/mysql/lucky_pool_adjustment_repository_test.go @@ -0,0 +1,163 @@ +package mysql + +import ( + "context" + "sync" + "testing" + + "hyapp/internal/testutil/mysqlschema" + "hyapp/pkg/appcode" + "hyapp/pkg/xerr" + domain "hyapp/services/lucky-gift-service/internal/domain/luckygift" +) + +func TestAdjustLuckyGiftPoolBalanceSeparatesStrategiesAndReplaysIdempotently(t *testing.T) { + caller := mysqlschema.CallerFile(t, 1) + schema := mysqlschema.New(t, mysqlschema.Config{ + EnvVar: "LUCKY_GIFT_SERVICE_MYSQL_TEST_DSN", + InitDBPath: mysqlschema.InitDBPath(t, caller, "..", "..", "..", "deploy", "mysql", "initdb", "001_lucky_gift_service.sql"), + DatabasePrefix: "hy_lucky_gift", + }) + repo := &Repository{db: schema.DB} + ctx := appcode.WithContext(context.Background(), "aslan") + + fixed := dynamicMySQLTestRule("shared-pool") + fixed.StrategyVersion = domain.StrategyFixedV2 + if _, err := repo.PublishLuckyGiftRuleConfig(ctx, fixed, 1_700_010_000_000); err != nil { + t.Fatalf("publish fixed config: %v", err) + } + dynamic := dynamicMySQLTestRule("shared-pool") + if _, err := repo.PublishLuckyGiftRuleConfig(ctx, dynamic, 1_700_010_000_100); err != nil { + t.Fatalf("publish dynamic config: %v", err) + } + + fixedResult, err := repo.AdjustLuckyGiftPoolBalance(ctx, domain.PoolAdjustmentCommand{ + AppCode: "aslan", PoolID: "shared-pool", StrategyVersion: domain.StrategyFixedV2, + AdjustmentID: "fixed-credit-1", Direction: domain.PoolAdjustmentIn, AmountCoins: 10, + Reason: "legacy reserve", OperatorAdminID: 7, + }, 1_700_010_001_000) + if err != nil { + t.Fatalf("credit fixed pool: %v", err) + } + if fixedResult.Pool.Balance != 3_835 || fixedResult.Pool.ReserveFloor != 210 { + t.Fatalf("fixed pool snapshot=%+v, want old 3825/210 seed plus 10", fixedResult.Pool) + } + + credit := domain.PoolAdjustmentCommand{ + AppCode: "aslan", PoolID: "shared-pool", StrategyVersion: domain.StrategyDynamicV3, + AdjustmentID: "dynamic-credit-1", Direction: domain.PoolAdjustmentIn, AmountCoins: 100, + Reason: "approved launch budget", OperatorAdminID: 7, RequestID: "request-1", + } + first, err := repo.AdjustLuckyGiftPoolBalance(ctx, credit, 1_700_010_002_000) + if err != nil { + t.Fatalf("credit dynamic pool: %v", err) + } + if first.Adjustment.BalanceBefore != 0 || first.Adjustment.BalanceAfter != 100 || first.Pool.ManualCreditTotal != 100 || first.Pool.TotalIn != 0 { + t.Fatalf("dynamic credit result=%+v, want 0->100 isolated manual credit", first) + } + + replayCommand := credit + replayCommand.RequestID = "request-retry" + replay, err := repo.AdjustLuckyGiftPoolBalance(ctx, replayCommand, 1_700_010_003_000) + if err != nil { + t.Fatalf("replay dynamic credit: %v", err) + } + if !replay.IdempotentReplay || replay.Adjustment.BalanceBefore != 0 || replay.Pool.Balance != 100 { + t.Fatalf("replay result=%+v, want original snapshot", replay) + } + mismatch := credit + mismatch.AmountCoins = 101 + if _, err := repo.AdjustLuckyGiftPoolBalance(ctx, mismatch, 1_700_010_004_000); !xerr.IsCode(err, xerr.IdempotencyConflict) { + t.Fatalf("same ID with different payload error=%v, want idempotency conflict", err) + } + + debit := domain.PoolAdjustmentCommand{ + AppCode: "aslan", PoolID: "shared-pool", StrategyVersion: domain.StrategyDynamicV3, + AdjustmentID: "dynamic-debit-1", Direction: domain.PoolAdjustmentOut, AmountCoins: 30, + Reason: "return unused budget", OperatorAdminID: 7, + } + debitResult, err := repo.AdjustLuckyGiftPoolBalance(ctx, debit, 1_700_010_005_000) + if err != nil { + t.Fatalf("debit dynamic pool: %v", err) + } + if debitResult.Pool.Balance != 70 || debitResult.Pool.ManualDebitTotal != 30 || debitResult.Pool.TotalOut != 0 { + t.Fatalf("dynamic debit result=%+v, want manual 30 without business total_out", debitResult) + } + overDebit := debit + overDebit.AdjustmentID = "dynamic-debit-over-reserve" + overDebit.AmountCoins = 71 + if _, err := repo.AdjustLuckyGiftPoolBalance(ctx, overDebit, 1_700_010_006_000); !xerr.IsCode(err, xerr.Conflict) { + t.Fatalf("over-reserve debit error=%v, want conflict", err) + } + + // 八个并发重试共享一个 adjustment_id;唯一键和 FOR UPDATE 必须只让一次 10 金币入账。 + concurrent := credit + concurrent.AdjustmentID = "dynamic-credit-concurrent" + concurrent.AmountCoins = 10 + concurrent.Reason = "single concurrent grant" + var wg sync.WaitGroup + errorsByWorker := make([]error, 8) + for index := range errorsByWorker { + wg.Add(1) + go func(worker int) { + defer wg.Done() + command := concurrent + command.RequestID = "concurrent-request" + _, errorsByWorker[worker] = repo.AdjustLuckyGiftPoolBalance(ctx, command, 1_700_010_007_000) + }(index) + } + wg.Wait() + for index, workerErr := range errorsByWorker { + if workerErr != nil { + t.Fatalf("concurrent worker %d: %v", index, workerErr) + } + } + + var dynamicBalance, dynamicManualCredit, dynamicManualDebit, dynamicTotalIn, dynamicTotalOut int64 + if err := schema.DB.QueryRow(` + SELECT balance, manual_credit_total, manual_debit_total, total_in, total_out + FROM lucky_pools WHERE app_code='aslan' AND scope_type='pool_dynamic_v3' AND scope_id='shared-pool'`, + ).Scan(&dynamicBalance, &dynamicManualCredit, &dynamicManualDebit, &dynamicTotalIn, &dynamicTotalOut); err != nil { + t.Fatalf("query dynamic pool: %v", err) + } + if dynamicBalance != 80 || dynamicManualCredit != 110 || dynamicManualDebit != 30 || dynamicTotalIn != 0 || dynamicTotalOut != 0 { + t.Fatalf("dynamic ledger balance/credit/debit/in/out=%d/%d/%d/%d/%d, want 80/110/30/0/0", dynamicBalance, dynamicManualCredit, dynamicManualDebit, dynamicTotalIn, dynamicTotalOut) + } + var fixedBalance int64 + if err := schema.DB.QueryRow(` + SELECT balance FROM lucky_pools WHERE app_code='aslan' AND scope_type='pool' AND scope_id='shared-pool'`, + ).Scan(&fixedBalance); err != nil || fixedBalance != 3_835 { + t.Fatalf("fixed ledger balance=%d err=%v, want isolated 3835", fixedBalance, err) + } + + items, err := repo.ListLuckyGiftPoolBalances(ctx, domain.PoolBalanceQuery{AppCode: "aslan", PoolID: "shared-pool"}) + if err != nil { + t.Fatalf("list both strategy ledgers: %v", err) + } + if len(items) != 2 || items[0].StrategyVersion == items[1].StrategyVersion { + t.Fatalf("strategy-separated pool list=%+v, want fixed_v2 and dynamic_v3", items) + } +} + +func TestAdjustLuckyGiftPoolBalanceRejectsUnconfiguredStrategy(t *testing.T) { + caller := mysqlschema.CallerFile(t, 1) + schema := mysqlschema.New(t, mysqlschema.Config{ + EnvVar: "LUCKY_GIFT_SERVICE_MYSQL_TEST_DSN", + InitDBPath: mysqlschema.InitDBPath(t, caller, "..", "..", "..", "deploy", "mysql", "initdb", "001_lucky_gift_service.sql"), + DatabasePrefix: "hy_lucky_gift", + }) + repo := &Repository{db: schema.DB} + ctx := appcode.WithContext(context.Background(), "aslan") + _, err := repo.AdjustLuckyGiftPoolBalance(ctx, domain.PoolAdjustmentCommand{ + AppCode: "aslan", PoolID: "not-configured", StrategyVersion: domain.StrategyDynamicV3, + AdjustmentID: "orphan-credit", Direction: domain.PoolAdjustmentIn, AmountCoins: 1, + Reason: "must fail", OperatorAdminID: 7, + }, 1_700_020_000_000) + if !xerr.IsCode(err, xerr.NotFound) { + t.Fatalf("unconfigured strategy error=%v, want not found", err) + } + var count int + if scanErr := schema.DB.QueryRow(`SELECT COUNT(*) FROM lucky_pools WHERE app_code='aslan' AND scope_id='not-configured'`).Scan(&count); scanErr != nil || count != 0 { + t.Fatalf("orphan pool count=%d err=%v, want 0", count, scanErr) + } +} diff --git a/services/lucky-gift-service/internal/transport/grpc/lucky_gift_server.go b/services/lucky-gift-service/internal/transport/grpc/lucky_gift_server.go index c9a43f13..d53f2f27 100644 --- a/services/lucky-gift-service/internal/transport/grpc/lucky_gift_server.go +++ b/services/lucky-gift-service/internal/transport/grpc/lucky_gift_server.go @@ -228,8 +228,9 @@ func (s *AdminLuckyGiftServer) ListLuckyGiftPoolBalances(ctx context.Context, re ctx = appcode.WithContext(ctx, appCode) } pools, err := s.svc.ListPoolBalances(ctx, domain.PoolBalanceQuery{ - AppCode: appCode, - PoolID: req.GetPoolId(), + AppCode: appCode, + PoolID: req.GetPoolId(), + StrategyVersion: req.GetStrategyVersion(), }) if err != nil { return nil, xerr.ToGRPCError(err) @@ -241,6 +242,32 @@ func (s *AdminLuckyGiftServer) ListLuckyGiftPoolBalances(ctx context.Context, re return resp, nil } +func (s *AdminLuckyGiftServer) AdjustLuckyGiftPoolBalance(ctx context.Context, req *luckygiftv1.AdjustLuckyGiftPoolBalanceRequest) (*luckygiftv1.AdjustLuckyGiftPoolBalanceResponse, error) { + if req == nil || strings.TrimSpace(req.GetMeta().GetAppCode()) == "" { + // 资金 RPC 必须 fail-closed;缺失 meta 不能像普通兼容查询一样静默落到默认 lalu 租户。 + return nil, xerr.ToGRPCError(xerr.New(xerr.InvalidArgument, "lucky gift pool adjustment app_code is required")) + } + ctx = appcode.WithContext(ctx, req.GetMeta().GetAppCode()) + result, err := s.svc.AdjustPoolBalance(ctx, domain.PoolAdjustmentCommand{ + PoolID: req.GetPoolId(), + StrategyVersion: req.GetStrategyVersion(), + AdjustmentID: req.GetAdjustmentId(), + Direction: req.GetDirection(), + AmountCoins: req.GetAmountCoins(), + Reason: req.GetReason(), + OperatorAdminID: req.GetOperatorAdminId(), + RequestID: req.GetMeta().GetRequestId(), + }) + if err != nil { + return nil, xerr.ToGRPCError(err) + } + return &luckygiftv1.AdjustLuckyGiftPoolBalanceResponse{ + Adjustment: luckyPoolAdjustmentToProto(result.Adjustment), + Pool: luckyPoolBalanceToProto(result.Pool), + IdempotentReplay: result.IdempotentReplay, + }, nil +} + func luckyRuleConfigFromProto(config *luckygiftv1.LuckyGiftRuleConfig) domain.RuleConfig { if config == nil { return domain.RuleConfig{} @@ -440,14 +467,33 @@ func luckyDrawSummaryToProto(summary domain.DrawSummary) *luckygiftv1.LuckyGiftD func luckyPoolBalanceToProto(pool domain.PoolBalance) *luckygiftv1.LuckyGiftPoolBalance { return &luckygiftv1.LuckyGiftPoolBalance{ - AppCode: pool.AppCode, - PoolId: pool.PoolID, - Balance: pool.Balance, - ReserveFloor: pool.ReserveFloor, - AvailableBalance: pool.AvailableBalance, - TotalIn: pool.TotalIn, - TotalOut: pool.TotalOut, - Materialized: pool.Materialized, - UpdatedAtMs: pool.UpdatedAtMS, + AppCode: pool.AppCode, + PoolId: pool.PoolID, + StrategyVersion: pool.StrategyVersion, + Balance: pool.Balance, + ReserveFloor: pool.ReserveFloor, + AvailableBalance: pool.AvailableBalance, + TotalIn: pool.TotalIn, + TotalOut: pool.TotalOut, + ManualCreditTotal: pool.ManualCreditTotal, + ManualDebitTotal: pool.ManualDebitTotal, + Materialized: pool.Materialized, + UpdatedAtMs: pool.UpdatedAtMS, + } +} + +func luckyPoolAdjustmentToProto(adjustment domain.PoolAdjustment) *luckygiftv1.LuckyGiftPoolAdjustment { + return &luckygiftv1.LuckyGiftPoolAdjustment{ + AdjustmentId: adjustment.AdjustmentID, + AppCode: adjustment.AppCode, + PoolId: adjustment.PoolID, + StrategyVersion: adjustment.StrategyVersion, + Direction: adjustment.Direction, + AmountCoins: adjustment.AmountCoins, + Reason: adjustment.Reason, + OperatorAdminId: adjustment.OperatorAdminID, + BalanceBefore: adjustment.BalanceBefore, + BalanceAfter: adjustment.BalanceAfter, + CreatedAtMs: adjustment.CreatedAtMS, } } diff --git a/services/lucky-gift-service/internal/transport/grpc/lucky_gift_server_test.go b/services/lucky-gift-service/internal/transport/grpc/lucky_gift_server_test.go index 1aa4331b..a5674a8b 100644 --- a/services/lucky-gift-service/internal/transport/grpc/lucky_gift_server_test.go +++ b/services/lucky-gift-service/internal/transport/grpc/lucky_gift_server_test.go @@ -5,8 +5,24 @@ import ( "testing" luckygiftv1 "hyapp.local/api/proto/luckygift/v1" + domain "hyapp/services/lucky-gift-service/internal/domain/luckygift" + service "hyapp/services/lucky-gift-service/internal/service/luckygift" + + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" ) +type poolAdjustmentGRPCRepository struct { + service.Repository + command domain.PoolAdjustmentCommand + result domain.PoolAdjustmentResult +} + +func (r *poolAdjustmentGRPCRepository) AdjustLuckyGiftPoolBalance(_ context.Context, command domain.PoolAdjustmentCommand, _ int64) (domain.PoolAdjustmentResult, error) { + r.command = command + return r.result, nil +} + func TestBatchExecuteLuckyGiftDrawRejectsMixedAppCodes(t *testing.T) { server := NewLuckyGiftServer(nil) _, err := server.BatchExecuteLuckyGiftDraw(context.Background(), &luckygiftv1.BatchExecuteLuckyGiftDrawRequest{ @@ -19,3 +35,42 @@ func TestBatchExecuteLuckyGiftDrawRejectsMixedAppCodes(t *testing.T) { t.Fatal("expected mixed app_code batch to be rejected") } } + +func TestAdjustLuckyGiftPoolBalanceMapsExplicitStrategyAndSnapshot(t *testing.T) { + repository := &poolAdjustmentGRPCRepository{result: domain.PoolAdjustmentResult{ + Adjustment: domain.PoolAdjustment{ + AdjustmentID: "adjustment-1", AppCode: "aslan", PoolID: "lucky", StrategyVersion: domain.StrategyDynamicV3, + Direction: domain.PoolAdjustmentIn, AmountCoins: 100, BalanceBefore: 0, BalanceAfter: 100, + }, + Pool: domain.PoolBalance{ + AppCode: "aslan", PoolID: "lucky", StrategyVersion: domain.StrategyDynamicV3, + Balance: 100, ManualCreditTotal: 100, Materialized: true, + }, + }} + server := NewAdminLuckyGiftServer(service.New(repository)) + resp, err := server.AdjustLuckyGiftPoolBalance(context.Background(), &luckygiftv1.AdjustLuckyGiftPoolBalanceRequest{ + Meta: &luckygiftv1.RequestMeta{AppCode: "aslan", RequestId: "request-1"}, + PoolId: "lucky", StrategyVersion: domain.StrategyDynamicV3, AdjustmentId: "adjustment-1", + Direction: domain.PoolAdjustmentIn, AmountCoins: 100, Reason: "launch", OperatorAdminId: 7, + }) + if err != nil { + t.Fatalf("adjust pool RPC: %v", err) + } + if repository.command.AppCode != "aslan" || repository.command.RequestID != "request-1" || repository.command.StrategyVersion != domain.StrategyDynamicV3 { + t.Fatalf("owner command=%+v", repository.command) + } + if resp.GetPool().GetStrategyVersion() != domain.StrategyDynamicV3 || resp.GetPool().GetManualCreditTotal() != 100 || resp.GetAdjustment().GetBalanceAfter() != 100 { + t.Fatalf("RPC response=%+v", resp) + } +} + +func TestAdjustLuckyGiftPoolBalanceRejectsMissingAppCodeBeforeOwnerCall(t *testing.T) { + server := NewAdminLuckyGiftServer(nil) + _, err := server.AdjustLuckyGiftPoolBalance(context.Background(), &luckygiftv1.AdjustLuckyGiftPoolBalanceRequest{ + Meta: &luckygiftv1.RequestMeta{}, PoolId: "lucky", StrategyVersion: domain.StrategyDynamicV3, + AdjustmentId: "adjustment-1", Direction: domain.PoolAdjustmentIn, AmountCoins: 1, Reason: "test", OperatorAdminId: 7, + }) + if status.Code(err) != codes.InvalidArgument { + t.Fatalf("missing app_code error=%v, want InvalidArgument", err) + } +}