feat(lucky-gift): isolate strategy pools and audit balance adjustments

This commit is contained in:
zhx 2026-07-15 16:09:54 +08:00
parent 0cdbd28d46
commit eaeff155f8
30 changed files with 2119 additions and 302 deletions

View File

@ -2282,6 +2282,9 @@ type LuckyGiftPoolBalance struct {
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
@ -2530,6 +2554,7 @@ type ListLuckyGiftPoolBalancesRequest struct {
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,7 +3753,7 @@ func file_proto_luckygift_v1_luckygift_proto_rawDescGZIP() []byte {
return file_proto_luckygift_v1_luckygift_proto_rawDescData
}
var file_proto_luckygift_v1_luckygift_proto_msgTypes = make([]protoimpl.MessageInfo, 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
@ -3383,6 +3784,9 @@ var file_proto_luckygift_v1_luckygift_proto_goTypes = []any{
(*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,
},

View File

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

View File

@ -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
@ -241,6 +241,7 @@ const (
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",

View File

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

View File

@ -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 {
@ -130,15 +132,44 @@ type drawSummaryDTO struct {
type poolBalanceDTO struct {
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) {
if strings.TrimSpace(c.Query("app_code")) == "" {
response.BadRequest(c, "app_code is required")
@ -275,6 +306,7 @@ func (h *Handler) ListLuckyGiftPoolBalances(c *gin.Context) {
resp, err := h.luckyGift.ListLuckyGiftPoolBalances(ctx, &luckygiftv1.ListLuckyGiftPoolBalancesRequest{
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()),
@ -553,16 +666,38 @@ func poolBalanceFromProto(pool *luckygiftv1.LuckyGiftPoolBalance) poolBalanceDTO
return poolBalanceDTO{
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(),
}
}
func parseOptionalInt64(value string) int64 {
value = strings.TrimSpace(value)
if value == "" {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -23,6 +23,9 @@ const (
StrategyFixedV2 = "fixed_v2"
StrategyDynamicV3 = "dynamic_v3"
PoolAdjustmentIn = "in"
PoolAdjustmentOut = "out"
EventTypeLuckyGiftDrawn = "LuckyGiftDrawn"
EventTypeLuckyGiftRewardSettlement = "LuckyGiftRewardSettlement"
EventTypeExternalLuckyGiftDrawn = "ExternalLuckyGiftDrawn"
@ -253,11 +256,14 @@ type DrawSummary struct {
type PoolBalance struct {
AppCode string
PoolID string
StrategyVersion string
Balance int64
ReserveFloor int64
AvailableBalance int64
TotalIn int64
TotalOut int64
ManualCreditTotal int64
ManualDebitTotal int64
Materialized bool
UpdatedAtMS int64
}
@ -265,6 +271,41 @@ type PoolBalance struct {
type PoolBalanceQuery struct {
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 {

View File

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

View File

@ -29,7 +29,8 @@ func DefaultRuleConfig(appCode, poolID string) domain.RuleConfig {
SettlementWindowWager: 1_000_000,
ControlBandPPM: 30_000,
GiftPriceReference: 100,
InitialPoolCoins: 1_000_000,
// 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")

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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 {
@ -53,6 +56,8 @@ type luckyPool struct {
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 {
var defaultBalance, defaultReserve int64
if defaultStrategy == domain.StrategyFixedV2 {
const defaultReferencePrice = int64(100)
defaultBalance := defaultReferencePrice * (950 + 500 + 2_375)
defaultReserve := defaultReferencePrice * (100 + 10 + 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
}
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
}

View File

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

View File

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

View File

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

View File

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

View File

@ -230,6 +230,7 @@ func (s *AdminLuckyGiftServer) ListLuckyGiftPoolBalances(ctx context.Context, re
pools, err := s.svc.ListPoolBalances(ctx, domain.PoolBalanceQuery{
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{}
@ -442,12 +469,31 @@ func luckyPoolBalanceToProto(pool domain.PoolBalance) *luckygiftv1.LuckyGiftPool
return &luckygiftv1.LuckyGiftPoolBalance{
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,
}
}

View File

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