merge: integrate lucky gift dynamic strategy

This commit is contained in:
zhx 2026-07-16 19:22:35 +08:00
commit 9ee45a3a52
117 changed files with 13831 additions and 932 deletions

View File

@ -122,6 +122,12 @@ type LuckyGiftMeta struct {
SenderAvatar string `protobuf:"bytes,16,opt,name=sender_avatar,json=senderAvatar,proto3" json:"sender_avatar,omitempty"` SenderAvatar string `protobuf:"bytes,16,opt,name=sender_avatar,json=senderAvatar,proto3" json:"sender_avatar,omitempty"`
SenderDisplayUserId string `protobuf:"bytes,17,opt,name=sender_display_user_id,json=senderDisplayUserId,proto3" json:"sender_display_user_id,omitempty"` SenderDisplayUserId string `protobuf:"bytes,17,opt,name=sender_display_user_id,json=senderDisplayUserId,proto3" json:"sender_display_user_id,omitempty"`
SenderPrettyDisplayUserId string `protobuf:"bytes,18,opt,name=sender_pretty_display_user_id,json=senderPrettyDisplayUserId,proto3" json:"sender_pretty_display_user_id,omitempty"` SenderPrettyDisplayUserId string `protobuf:"bytes,18,opt,name=sender_pretty_display_user_id,json=senderPrettyDisplayUserId,proto3" json:"sender_pretty_display_user_id,omitempty"`
// 充值分层与充值后短时加权必须由调用方传入已确认的用户事实;幸运礼物服务不跨库回查用户或支付明细。
Recharge_7DCoins int64 `protobuf:"varint,19,opt,name=recharge_7d_coins,json=recharge7dCoins,proto3" json:"recharge_7d_coins,omitempty"`
Recharge_30DCoins int64 `protobuf:"varint,20,opt,name=recharge_30d_coins,json=recharge30dCoins,proto3" json:"recharge_30d_coins,omitempty"`
LastRechargedAtMs int64 `protobuf:"varint,21,opt,name=last_recharged_at_ms,json=lastRechargedAtMs,proto3" json:"last_recharged_at_ms,omitempty"`
// gift_income_coins 是 wallet 已实际入账给收礼人的收益金币dynamic_v3 用它校验规则主播拆账,缺失或比例不一致均拒绝开奖。
GiftIncomeCoins int64 `protobuf:"varint,22,opt,name=gift_income_coins,json=giftIncomeCoins,proto3" json:"gift_income_coins,omitempty"`
unknownFields protoimpl.UnknownFields unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache sizeCache protoimpl.SizeCache
} }
@ -282,6 +288,34 @@ func (x *LuckyGiftMeta) GetSenderPrettyDisplayUserId() string {
return "" return ""
} }
func (x *LuckyGiftMeta) GetRecharge_7DCoins() int64 {
if x != nil {
return x.Recharge_7DCoins
}
return 0
}
func (x *LuckyGiftMeta) GetRecharge_30DCoins() int64 {
if x != nil {
return x.Recharge_30DCoins
}
return 0
}
func (x *LuckyGiftMeta) GetLastRechargedAtMs() int64 {
if x != nil {
return x.LastRechargedAtMs
}
return 0
}
func (x *LuckyGiftMeta) GetGiftIncomeCoins() int64 {
if x != nil {
return x.GiftIncomeCoins
}
return 0
}
type LuckyGiftRuleTier struct { type LuckyGiftRuleTier struct {
state protoimpl.MessageState `protogen:"open.v1"` state protoimpl.MessageState `protogen:"open.v1"`
Stage string `protobuf:"bytes,1,opt,name=stage,proto3" json:"stage,omitempty"` Stage string `protobuf:"bytes,1,opt,name=stage,proto3" json:"stage,omitempty"`
@ -370,6 +404,9 @@ type LuckyGiftRuleStage struct {
state protoimpl.MessageState `protogen:"open.v1"` state protoimpl.MessageState `protogen:"open.v1"`
Stage string `protobuf:"bytes,1,opt,name=stage,proto3" json:"stage,omitempty"` Stage string `protobuf:"bytes,1,opt,name=stage,proto3" json:"stage,omitempty"`
Tiers []*LuckyGiftRuleTier `protobuf:"bytes,2,rep,name=tiers,proto3" json:"tiers,omitempty"` Tiers []*LuckyGiftRuleTier `protobuf:"bytes,2,rep,name=tiers,proto3" json:"tiers,omitempty"`
// 两个充值门槛共同决定 dynamic_v3 分层novice 固定为 0/0后续阶段逐维单调递增。
MinRecharge_7DCoins int64 `protobuf:"varint,3,opt,name=min_recharge_7d_coins,json=minRecharge7dCoins,proto3" json:"min_recharge_7d_coins,omitempty"`
MinRecharge_30DCoins int64 `protobuf:"varint,4,opt,name=min_recharge_30d_coins,json=minRecharge30dCoins,proto3" json:"min_recharge_30d_coins,omitempty"`
unknownFields protoimpl.UnknownFields unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache sizeCache protoimpl.SizeCache
} }
@ -418,6 +455,20 @@ func (x *LuckyGiftRuleStage) GetTiers() []*LuckyGiftRuleTier {
return nil return nil
} }
func (x *LuckyGiftRuleStage) GetMinRecharge_7DCoins() int64 {
if x != nil {
return x.MinRecharge_7DCoins
}
return 0
}
func (x *LuckyGiftRuleStage) GetMinRecharge_30DCoins() int64 {
if x != nil {
return x.MinRecharge_30DCoins
}
return 0
}
type LuckyGiftRuleConfig struct { type LuckyGiftRuleConfig struct {
state protoimpl.MessageState `protogen:"open.v1"` state protoimpl.MessageState `protogen:"open.v1"`
AppCode string `protobuf:"bytes,1,opt,name=app_code,json=appCode,proto3" json:"app_code,omitempty"` AppCode string `protobuf:"bytes,1,opt,name=app_code,json=appCode,proto3" json:"app_code,omitempty"`
@ -435,6 +486,30 @@ type LuckyGiftRuleConfig struct {
CreatedByAdminId int64 `protobuf:"varint,13,opt,name=created_by_admin_id,json=createdByAdminId,proto3" json:"created_by_admin_id,omitempty"` CreatedByAdminId int64 `protobuf:"varint,13,opt,name=created_by_admin_id,json=createdByAdminId,proto3" json:"created_by_admin_id,omitempty"`
CreatedAtMs int64 `protobuf:"varint,14,opt,name=created_at_ms,json=createdAtMs,proto3" json:"created_at_ms,omitempty"` CreatedAtMs int64 `protobuf:"varint,14,opt,name=created_at_ms,json=createdAtMs,proto3" json:"created_at_ms,omitempty"`
Stages []*LuckyGiftRuleStage `protobuf:"bytes,15,rep,name=stages,proto3" json:"stages,omitempty"` Stages []*LuckyGiftRuleStage `protobuf:"bytes,15,rep,name=stages,proto3" json:"stages,omitempty"`
// strategy_version 隔离历史 fixed_v2 与动态算法 dynamic_v3避免新增约束破坏已经发布的旧规则。
StrategyVersion string `protobuf:"bytes,16,opt,name=strategy_version,json=strategyVersion,proto3" json:"strategy_version,omitempty"`
ProfitRatePpm int64 `protobuf:"varint,17,opt,name=profit_rate_ppm,json=profitRatePpm,proto3" json:"profit_rate_ppm,omitempty"`
AnchorRatePpm int64 `protobuf:"varint,18,opt,name=anchor_rate_ppm,json=anchorRatePpm,proto3" json:"anchor_rate_ppm,omitempty"`
InitialPoolCoins int64 `protobuf:"varint,19,opt,name=initial_pool_coins,json=initialPoolCoins,proto3" json:"initial_pool_coins,omitempty"`
LossStreakGuarantee int64 `protobuf:"varint,20,opt,name=loss_streak_guarantee,json=lossStreakGuarantee,proto3" json:"loss_streak_guarantee,omitempty"`
LowWatermarkCoins int64 `protobuf:"varint,21,opt,name=low_watermark_coins,json=lowWatermarkCoins,proto3" json:"low_watermark_coins,omitempty"`
LowWaterNonzeroFactorPpm int64 `protobuf:"varint,22,opt,name=low_water_nonzero_factor_ppm,json=lowWaterNonzeroFactorPpm,proto3" json:"low_water_nonzero_factor_ppm,omitempty"`
HighWatermarkCoins int64 `protobuf:"varint,23,opt,name=high_watermark_coins,json=highWatermarkCoins,proto3" json:"high_watermark_coins,omitempty"`
HighWaterNonzeroFactorPpm int64 `protobuf:"varint,24,opt,name=high_water_nonzero_factor_ppm,json=highWaterNonzeroFactorPpm,proto3" json:"high_water_nonzero_factor_ppm,omitempty"`
RechargeBoostWindowMs int64 `protobuf:"varint,25,opt,name=recharge_boost_window_ms,json=rechargeBoostWindowMs,proto3" json:"recharge_boost_window_ms,omitempty"`
RechargeBoostFactorPpm int64 `protobuf:"varint,26,opt,name=recharge_boost_factor_ppm,json=rechargeBoostFactorPpm,proto3" json:"recharge_boost_factor_ppm,omitempty"`
JackpotMultiplierPpms []int64 `protobuf:"varint,27,rep,packed,name=jackpot_multiplier_ppms,json=jackpotMultiplierPpms,proto3" json:"jackpot_multiplier_ppms,omitempty"`
JackpotGlobalRtpMaxPpm int64 `protobuf:"varint,28,opt,name=jackpot_global_rtp_max_ppm,json=jackpotGlobalRtpMaxPpm,proto3" json:"jackpot_global_rtp_max_ppm,omitempty"`
JackpotUserDayRtpMaxPpm int64 `protobuf:"varint,29,opt,name=jackpot_user_day_rtp_max_ppm,json=jackpotUserDayRtpMaxPpm,proto3" json:"jackpot_user_day_rtp_max_ppm,omitempty"`
JackpotUser_72HRtpMaxPpm int64 `protobuf:"varint,30,opt,name=jackpot_user_72h_rtp_max_ppm,json=jackpotUser72hRtpMaxPpm,proto3" json:"jackpot_user_72h_rtp_max_ppm,omitempty"`
JackpotSpendThresholdCoins int64 `protobuf:"varint,31,opt,name=jackpot_spend_threshold_coins,json=jackpotSpendThresholdCoins,proto3" json:"jackpot_spend_threshold_coins,omitempty"`
MaxJackpotHitsPerUserDay int64 `protobuf:"varint,32,opt,name=max_jackpot_hits_per_user_day,json=maxJackpotHitsPerUserDay,proto3" json:"max_jackpot_hits_per_user_day,omitempty"`
MaxSinglePayout int64 `protobuf:"varint,33,opt,name=max_single_payout,json=maxSinglePayout,proto3" json:"max_single_payout,omitempty"`
UserHourlyPayoutCap int64 `protobuf:"varint,34,opt,name=user_hourly_payout_cap,json=userHourlyPayoutCap,proto3" json:"user_hourly_payout_cap,omitempty"`
UserDailyPayoutCap int64 `protobuf:"varint,35,opt,name=user_daily_payout_cap,json=userDailyPayoutCap,proto3" json:"user_daily_payout_cap,omitempty"`
DeviceDailyPayoutCap int64 `protobuf:"varint,36,opt,name=device_daily_payout_cap,json=deviceDailyPayoutCap,proto3" json:"device_daily_payout_cap,omitempty"`
RoomHourlyPayoutCap int64 `protobuf:"varint,37,opt,name=room_hourly_payout_cap,json=roomHourlyPayoutCap,proto3" json:"room_hourly_payout_cap,omitempty"`
AnchorDailyPayoutCap int64 `protobuf:"varint,38,opt,name=anchor_daily_payout_cap,json=anchorDailyPayoutCap,proto3" json:"anchor_daily_payout_cap,omitempty"`
unknownFields protoimpl.UnknownFields unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache sizeCache protoimpl.SizeCache
} }
@ -574,6 +649,167 @@ func (x *LuckyGiftRuleConfig) GetStages() []*LuckyGiftRuleStage {
return nil return nil
} }
func (x *LuckyGiftRuleConfig) GetStrategyVersion() string {
if x != nil {
return x.StrategyVersion
}
return ""
}
func (x *LuckyGiftRuleConfig) GetProfitRatePpm() int64 {
if x != nil {
return x.ProfitRatePpm
}
return 0
}
func (x *LuckyGiftRuleConfig) GetAnchorRatePpm() int64 {
if x != nil {
return x.AnchorRatePpm
}
return 0
}
func (x *LuckyGiftRuleConfig) GetInitialPoolCoins() int64 {
if x != nil {
return x.InitialPoolCoins
}
return 0
}
func (x *LuckyGiftRuleConfig) GetLossStreakGuarantee() int64 {
if x != nil {
return x.LossStreakGuarantee
}
return 0
}
func (x *LuckyGiftRuleConfig) GetLowWatermarkCoins() int64 {
if x != nil {
return x.LowWatermarkCoins
}
return 0
}
func (x *LuckyGiftRuleConfig) GetLowWaterNonzeroFactorPpm() int64 {
if x != nil {
return x.LowWaterNonzeroFactorPpm
}
return 0
}
func (x *LuckyGiftRuleConfig) GetHighWatermarkCoins() int64 {
if x != nil {
return x.HighWatermarkCoins
}
return 0
}
func (x *LuckyGiftRuleConfig) GetHighWaterNonzeroFactorPpm() int64 {
if x != nil {
return x.HighWaterNonzeroFactorPpm
}
return 0
}
func (x *LuckyGiftRuleConfig) GetRechargeBoostWindowMs() int64 {
if x != nil {
return x.RechargeBoostWindowMs
}
return 0
}
func (x *LuckyGiftRuleConfig) GetRechargeBoostFactorPpm() int64 {
if x != nil {
return x.RechargeBoostFactorPpm
}
return 0
}
func (x *LuckyGiftRuleConfig) GetJackpotMultiplierPpms() []int64 {
if x != nil {
return x.JackpotMultiplierPpms
}
return nil
}
func (x *LuckyGiftRuleConfig) GetJackpotGlobalRtpMaxPpm() int64 {
if x != nil {
return x.JackpotGlobalRtpMaxPpm
}
return 0
}
func (x *LuckyGiftRuleConfig) GetJackpotUserDayRtpMaxPpm() int64 {
if x != nil {
return x.JackpotUserDayRtpMaxPpm
}
return 0
}
func (x *LuckyGiftRuleConfig) GetJackpotUser_72HRtpMaxPpm() int64 {
if x != nil {
return x.JackpotUser_72HRtpMaxPpm
}
return 0
}
func (x *LuckyGiftRuleConfig) GetJackpotSpendThresholdCoins() int64 {
if x != nil {
return x.JackpotSpendThresholdCoins
}
return 0
}
func (x *LuckyGiftRuleConfig) GetMaxJackpotHitsPerUserDay() int64 {
if x != nil {
return x.MaxJackpotHitsPerUserDay
}
return 0
}
func (x *LuckyGiftRuleConfig) GetMaxSinglePayout() int64 {
if x != nil {
return x.MaxSinglePayout
}
return 0
}
func (x *LuckyGiftRuleConfig) GetUserHourlyPayoutCap() int64 {
if x != nil {
return x.UserHourlyPayoutCap
}
return 0
}
func (x *LuckyGiftRuleConfig) GetUserDailyPayoutCap() int64 {
if x != nil {
return x.UserDailyPayoutCap
}
return 0
}
func (x *LuckyGiftRuleConfig) GetDeviceDailyPayoutCap() int64 {
if x != nil {
return x.DeviceDailyPayoutCap
}
return 0
}
func (x *LuckyGiftRuleConfig) GetRoomHourlyPayoutCap() int64 {
if x != nil {
return x.RoomHourlyPayoutCap
}
return 0
}
func (x *LuckyGiftRuleConfig) GetAnchorDailyPayoutCap() int64 {
if x != nil {
return x.AnchorDailyPayoutCap
}
return 0
}
type CheckLuckyGiftRequest struct { type CheckLuckyGiftRequest struct {
state protoimpl.MessageState `protogen:"open.v1"` state protoimpl.MessageState `protogen:"open.v1"`
Meta *RequestMeta `protobuf:"bytes,1,opt,name=meta,proto3" json:"meta,omitempty"` Meta *RequestMeta `protobuf:"bytes,1,opt,name=meta,proto3" json:"meta,omitempty"`
@ -660,6 +896,8 @@ type CheckLuckyGiftResponse struct {
TargetRtpPpm int64 `protobuf:"varint,6,opt,name=target_rtp_ppm,json=targetRtpPpm,proto3" json:"target_rtp_ppm,omitempty"` TargetRtpPpm int64 `protobuf:"varint,6,opt,name=target_rtp_ppm,json=targetRtpPpm,proto3" json:"target_rtp_ppm,omitempty"`
ExperiencePool string `protobuf:"bytes,7,opt,name=experience_pool,json=experiencePool,proto3" json:"experience_pool,omitempty"` ExperiencePool string `protobuf:"bytes,7,opt,name=experience_pool,json=experiencePool,proto3" json:"experience_pool,omitempty"`
PoolId string `protobuf:"bytes,8,opt,name=pool_id,json=poolId,proto3" json:"pool_id,omitempty"` PoolId string `protobuf:"bytes,8,opt,name=pool_id,json=poolId,proto3" json:"pool_id,omitempty"`
// strategy_version 由 lucky-gift owner 返回并由 room saga 固化,用于滚动发布时区分 fixed_v2 兼容与 dynamic_v3 强校验。
StrategyVersion string `protobuf:"bytes,9,opt,name=strategy_version,json=strategyVersion,proto3" json:"strategy_version,omitempty"`
unknownFields protoimpl.UnknownFields unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache sizeCache protoimpl.SizeCache
} }
@ -750,6 +988,13 @@ func (x *CheckLuckyGiftResponse) GetPoolId() string {
return "" return ""
} }
func (x *CheckLuckyGiftResponse) GetStrategyVersion() string {
if x != nil {
return x.StrategyVersion
}
return ""
}
type LuckyGiftDrawResult struct { type LuckyGiftDrawResult struct {
state protoimpl.MessageState `protogen:"open.v1"` state protoimpl.MessageState `protogen:"open.v1"`
DrawId string `protobuf:"bytes,1,opt,name=draw_id,json=drawId,proto3" json:"draw_id,omitempty"` DrawId string `protobuf:"bytes,1,opt,name=draw_id,json=drawId,proto3" json:"draw_id,omitempty"`
@ -1169,6 +1414,8 @@ type ExternalGiftDrawRequest struct {
PaidAtMs int64 `protobuf:"varint,10,opt,name=paid_at_ms,json=paidAtMs,proto3" json:"paid_at_ms,omitempty"` PaidAtMs int64 `protobuf:"varint,10,opt,name=paid_at_ms,json=paidAtMs,proto3" json:"paid_at_ms,omitempty"`
MetadataJson string `protobuf:"bytes,11,opt,name=metadata_json,json=metadataJson,proto3" json:"metadata_json,omitempty"` MetadataJson string `protobuf:"bytes,11,opt,name=metadata_json,json=metadataJson,proto3" json:"metadata_json,omitempty"`
PoolId string `protobuf:"bytes,12,opt,name=pool_id,json=poolId,proto3" json:"pool_id,omitempty"` PoolId string `protobuf:"bytes,12,opt,name=pool_id,json=poolId,proto3" json:"pool_id,omitempty"`
// dynamic_v3 必填的稳定设备作用域。luck-gateway 不独立证明其真实性,调用方必须在自己的认证/签名边界内绑定该值fixed_v2 允许缺失。
DeviceId string `protobuf:"bytes,13,opt,name=device_id,json=deviceId,proto3" json:"device_id,omitempty"`
unknownFields protoimpl.UnknownFields unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache sizeCache protoimpl.SizeCache
} }
@ -1280,6 +1527,13 @@ func (x *ExternalGiftDrawRequest) GetPoolId() string {
return "" return ""
} }
func (x *ExternalGiftDrawRequest) GetDeviceId() string {
if x != nil {
return x.DeviceId
}
return ""
}
type ExternalGiftDrawResponse struct { type ExternalGiftDrawResponse struct {
state protoimpl.MessageState `protogen:"open.v1"` state protoimpl.MessageState `protogen:"open.v1"`
DrawId string `protobuf:"bytes,1,opt,name=draw_id,json=drawId,proto3" json:"draw_id,omitempty"` DrawId string `protobuf:"bytes,1,opt,name=draw_id,json=drawId,proto3" json:"draw_id,omitempty"`
@ -2003,6 +2257,9 @@ type LuckyGiftPoolBalance struct {
TotalOut int64 `protobuf:"varint,7,opt,name=total_out,json=totalOut,proto3" json:"total_out,omitempty"` TotalOut int64 `protobuf:"varint,7,opt,name=total_out,json=totalOut,proto3" json:"total_out,omitempty"`
Materialized bool `protobuf:"varint,8,opt,name=materialized,proto3" json:"materialized,omitempty"` 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"` 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"`
unknownFields protoimpl.UnknownFields unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache sizeCache protoimpl.SizeCache
} }
@ -2100,6 +2357,27 @@ func (x *LuckyGiftPoolBalance) GetUpdatedAtMs() int64 {
return 0 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 { type GetLuckyGiftDrawSummaryRequest struct {
state protoimpl.MessageState `protogen:"open.v1"` state protoimpl.MessageState `protogen:"open.v1"`
Meta *RequestMeta `protobuf:"bytes,1,opt,name=meta,proto3" json:"meta,omitempty"` Meta *RequestMeta `protobuf:"bytes,1,opt,name=meta,proto3" json:"meta,omitempty"`
@ -2248,6 +2526,7 @@ type ListLuckyGiftPoolBalancesRequest struct {
state protoimpl.MessageState `protogen:"open.v1"` state protoimpl.MessageState `protogen:"open.v1"`
Meta *RequestMeta `protobuf:"bytes,1,opt,name=meta,proto3" json:"meta,omitempty"` Meta *RequestMeta `protobuf:"bytes,1,opt,name=meta,proto3" json:"meta,omitempty"`
PoolId string `protobuf:"bytes,2,opt,name=pool_id,json=poolId,proto3" json:"pool_id,omitempty"` 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"`
unknownFields protoimpl.UnknownFields unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache sizeCache protoimpl.SizeCache
} }
@ -2296,6 +2575,13 @@ func (x *ListLuckyGiftPoolBalancesRequest) GetPoolId() string {
return "" return ""
} }
func (x *ListLuckyGiftPoolBalancesRequest) GetStrategyVersion() string {
if x != nil {
return x.StrategyVersion
}
return ""
}
type ListLuckyGiftPoolBalancesResponse struct { type ListLuckyGiftPoolBalancesResponse struct {
state protoimpl.MessageState `protogen:"open.v1"` state protoimpl.MessageState `protogen:"open.v1"`
Pools []*LuckyGiftPoolBalance `protobuf:"bytes,1,rep,name=pools,proto3" json:"pools,omitempty"` Pools []*LuckyGiftPoolBalance `protobuf:"bytes,1,rep,name=pools,proto3" json:"pools,omitempty"`
@ -2418,6 +2704,292 @@ func (x *LuckyGiftHit) GetRewardCoins() int64 {
return 0 return 0
} }
// 调账消息也只追加在文件尾,保持所有既有 protobuf message index 稳定。
// adjustment_id 是业务幂等键RequestMeta.request_id 只做链路追踪,不能替代资金幂等语义。
type AdjustLuckyGiftPoolBalanceRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
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"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
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 `protogen:"open.v1"`
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"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
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 `protogen:"open.v1"`
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"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
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 protoreflect.FileDescriptor
const file_proto_luckygift_v1_luckygift_proto_rawDesc = "" + const file_proto_luckygift_v1_luckygift_proto_rawDesc = "" +
@ -2430,7 +3002,7 @@ const file_proto_luckygift_v1_luckygift_proto_rawDesc = "" +
"\x0fgateway_node_id\x18\x03 \x01(\tR\rgatewayNodeId\x12\x1c\n" + "\x0fgateway_node_id\x18\x03 \x01(\tR\rgatewayNodeId\x12\x1c\n" +
"\n" + "\n" +
"sent_at_ms\x18\x04 \x01(\x03R\bsentAtMs\x12\x19\n" + "sent_at_ms\x18\x04 \x01(\x03R\bsentAtMs\x12\x19\n" +
"\bapp_code\x18\x05 \x01(\tR\aappCode\"\x8b\x05\n" + "\bapp_code\x18\x05 \x01(\tR\aappCode\"\xc2\x06\n" +
"\rLuckyGiftMeta\x123\n" + "\rLuckyGiftMeta\x123\n" +
"\x04meta\x18\x01 \x01(\v2\x1f.hyapp.luckygift.v1.RequestMetaR\x04meta\x12\x1d\n" + "\x04meta\x18\x01 \x01(\v2\x1f.hyapp.luckygift.v1.RequestMetaR\x04meta\x12\x1d\n" +
"\n" + "\n" +
@ -2456,17 +3028,23 @@ const file_proto_luckygift_v1_luckygift_proto_rawDesc = "" +
"senderName\x12#\n" + "senderName\x12#\n" +
"\rsender_avatar\x18\x10 \x01(\tR\fsenderAvatar\x123\n" + "\rsender_avatar\x18\x10 \x01(\tR\fsenderAvatar\x123\n" +
"\x16sender_display_user_id\x18\x11 \x01(\tR\x13senderDisplayUserId\x12@\n" + "\x16sender_display_user_id\x18\x11 \x01(\tR\x13senderDisplayUserId\x12@\n" +
"\x1dsender_pretty_display_user_id\x18\x12 \x01(\tR\x19senderPrettyDisplayUserId\"\xd3\x01\n" + "\x1dsender_pretty_display_user_id\x18\x12 \x01(\tR\x19senderPrettyDisplayUserId\x12*\n" +
"\x11recharge_7d_coins\x18\x13 \x01(\x03R\x0frecharge7dCoins\x12,\n" +
"\x12recharge_30d_coins\x18\x14 \x01(\x03R\x10recharge30dCoins\x12/\n" +
"\x14last_recharged_at_ms\x18\x15 \x01(\x03R\x11lastRechargedAtMs\x12*\n" +
"\x11gift_income_coins\x18\x16 \x01(\x03R\x0fgiftIncomeCoins\"\xd3\x01\n" +
"\x11LuckyGiftRuleTier\x12\x14\n" + "\x11LuckyGiftRuleTier\x12\x14\n" +
"\x05stage\x18\x01 \x01(\tR\x05stage\x12\x17\n" + "\x05stage\x18\x01 \x01(\tR\x05stage\x12\x17\n" +
"\atier_id\x18\x02 \x01(\tR\x06tierId\x12%\n" + "\atier_id\x18\x02 \x01(\tR\x06tierId\x12%\n" +
"\x0emultiplier_ppm\x18\x03 \x01(\x03R\rmultiplierPpm\x12&\n" + "\x0emultiplier_ppm\x18\x03 \x01(\x03R\rmultiplierPpm\x12&\n" +
"\x0fbase_weight_ppm\x18\x04 \x01(\x03R\rbaseWeightPpm\x12&\n" + "\x0fbase_weight_ppm\x18\x04 \x01(\x03R\rbaseWeightPpm\x12&\n" +
"\x0fhigh_water_only\x18\x05 \x01(\bR\rhighWaterOnly\x12\x18\n" + "\x0fhigh_water_only\x18\x05 \x01(\bR\rhighWaterOnly\x12\x18\n" +
"\aenabled\x18\x06 \x01(\bR\aenabled\"g\n" + "\aenabled\x18\x06 \x01(\bR\aenabled\"\xcf\x01\n" +
"\x12LuckyGiftRuleStage\x12\x14\n" + "\x12LuckyGiftRuleStage\x12\x14\n" +
"\x05stage\x18\x01 \x01(\tR\x05stage\x12;\n" + "\x05stage\x18\x01 \x01(\tR\x05stage\x12;\n" +
"\x05tiers\x18\x02 \x03(\v2%.hyapp.luckygift.v1.LuckyGiftRuleTierR\x05tiers\"\xa1\x05\n" + "\x05tiers\x18\x02 \x03(\v2%.hyapp.luckygift.v1.LuckyGiftRuleTierR\x05tiers\x121\n" +
"\x15min_recharge_7d_coins\x18\x03 \x01(\x03R\x12minRecharge7dCoins\x123\n" +
"\x16min_recharge_30d_coins\x18\x04 \x01(\x03R\x13minRecharge30dCoins\"\x83\x0f\n" +
"\x13LuckyGiftRuleConfig\x12\x19\n" + "\x13LuckyGiftRuleConfig\x12\x19\n" +
"\bapp_code\x18\x01 \x01(\tR\aappCode\x12\x17\n" + "\bapp_code\x18\x01 \x01(\tR\aappCode\x12\x17\n" +
"\apool_id\x18\x02 \x01(\tR\x06poolId\x12!\n" + "\apool_id\x18\x02 \x01(\tR\x06poolId\x12!\n" +
@ -2483,13 +3061,36 @@ const file_proto_luckygift_v1_luckygift_proto_rawDesc = "" +
"\x11effective_from_ms\x18\f \x01(\x03R\x0feffectiveFromMs\x12-\n" + "\x11effective_from_ms\x18\f \x01(\x03R\x0feffectiveFromMs\x12-\n" +
"\x13created_by_admin_id\x18\r \x01(\x03R\x10createdByAdminId\x12\"\n" + "\x13created_by_admin_id\x18\r \x01(\x03R\x10createdByAdminId\x12\"\n" +
"\rcreated_at_ms\x18\x0e \x01(\x03R\vcreatedAtMs\x12>\n" + "\rcreated_at_ms\x18\x0e \x01(\x03R\vcreatedAtMs\x12>\n" +
"\x06stages\x18\x0f \x03(\v2&.hyapp.luckygift.v1.LuckyGiftRuleStageR\x06stages\"\xb0\x01\n" + "\x06stages\x18\x0f \x03(\v2&.hyapp.luckygift.v1.LuckyGiftRuleStageR\x06stages\x12)\n" +
"\x10strategy_version\x18\x10 \x01(\tR\x0fstrategyVersion\x12&\n" +
"\x0fprofit_rate_ppm\x18\x11 \x01(\x03R\rprofitRatePpm\x12&\n" +
"\x0fanchor_rate_ppm\x18\x12 \x01(\x03R\ranchorRatePpm\x12,\n" +
"\x12initial_pool_coins\x18\x13 \x01(\x03R\x10initialPoolCoins\x122\n" +
"\x15loss_streak_guarantee\x18\x14 \x01(\x03R\x13lossStreakGuarantee\x12.\n" +
"\x13low_watermark_coins\x18\x15 \x01(\x03R\x11lowWatermarkCoins\x12>\n" +
"\x1clow_water_nonzero_factor_ppm\x18\x16 \x01(\x03R\x18lowWaterNonzeroFactorPpm\x120\n" +
"\x14high_watermark_coins\x18\x17 \x01(\x03R\x12highWatermarkCoins\x12@\n" +
"\x1dhigh_water_nonzero_factor_ppm\x18\x18 \x01(\x03R\x19highWaterNonzeroFactorPpm\x127\n" +
"\x18recharge_boost_window_ms\x18\x19 \x01(\x03R\x15rechargeBoostWindowMs\x129\n" +
"\x19recharge_boost_factor_ppm\x18\x1a \x01(\x03R\x16rechargeBoostFactorPpm\x126\n" +
"\x17jackpot_multiplier_ppms\x18\x1b \x03(\x03R\x15jackpotMultiplierPpms\x12:\n" +
"\x1ajackpot_global_rtp_max_ppm\x18\x1c \x01(\x03R\x16jackpotGlobalRtpMaxPpm\x12=\n" +
"\x1cjackpot_user_day_rtp_max_ppm\x18\x1d \x01(\x03R\x17jackpotUserDayRtpMaxPpm\x12=\n" +
"\x1cjackpot_user_72h_rtp_max_ppm\x18\x1e \x01(\x03R\x17jackpotUser72hRtpMaxPpm\x12A\n" +
"\x1djackpot_spend_threshold_coins\x18\x1f \x01(\x03R\x1ajackpotSpendThresholdCoins\x12?\n" +
"\x1dmax_jackpot_hits_per_user_day\x18 \x01(\x03R\x18maxJackpotHitsPerUserDay\x12*\n" +
"\x11max_single_payout\x18! \x01(\x03R\x0fmaxSinglePayout\x123\n" +
"\x16user_hourly_payout_cap\x18\" \x01(\x03R\x13userHourlyPayoutCap\x121\n" +
"\x15user_daily_payout_cap\x18# \x01(\x03R\x12userDailyPayoutCap\x125\n" +
"\x17device_daily_payout_cap\x18$ \x01(\x03R\x14deviceDailyPayoutCap\x123\n" +
"\x16room_hourly_payout_cap\x18% \x01(\x03R\x13roomHourlyPayoutCap\x125\n" +
"\x17anchor_daily_payout_cap\x18& \x01(\x03R\x14anchorDailyPayoutCap\"\xb0\x01\n" +
"\x15CheckLuckyGiftRequest\x123\n" + "\x15CheckLuckyGiftRequest\x123\n" +
"\x04meta\x18\x01 \x01(\v2\x1f.hyapp.luckygift.v1.RequestMetaR\x04meta\x12\x17\n" + "\x04meta\x18\x01 \x01(\v2\x1f.hyapp.luckygift.v1.RequestMetaR\x04meta\x12\x17\n" +
"\auser_id\x18\x02 \x01(\x03R\x06userId\x12\x17\n" + "\auser_id\x18\x02 \x01(\x03R\x06userId\x12\x17\n" +
"\aroom_id\x18\x03 \x01(\tR\x06roomId\x12\x17\n" + "\aroom_id\x18\x03 \x01(\tR\x06roomId\x12\x17\n" +
"\agift_id\x18\x04 \x01(\tR\x06giftId\x12\x17\n" + "\agift_id\x18\x04 \x01(\tR\x06giftId\x12\x17\n" +
"\apool_id\x18\x05 \x01(\tR\x06poolId\"\x8d\x02\n" + "\apool_id\x18\x05 \x01(\tR\x06poolId\"\xb8\x02\n" +
"\x16CheckLuckyGiftResponse\x12\x18\n" + "\x16CheckLuckyGiftResponse\x12\x18\n" +
"\aenabled\x18\x01 \x01(\bR\aenabled\x12\x16\n" + "\aenabled\x18\x01 \x01(\bR\aenabled\x12\x16\n" +
"\x06reason\x18\x02 \x01(\tR\x06reason\x12\x17\n" + "\x06reason\x18\x02 \x01(\tR\x06reason\x12\x17\n" +
@ -2499,7 +3100,8 @@ const file_proto_luckygift_v1_luckygift_proto_rawDesc = "" +
"\frule_version\x18\x05 \x01(\x03R\vruleVersion\x12$\n" + "\frule_version\x18\x05 \x01(\x03R\vruleVersion\x12$\n" +
"\x0etarget_rtp_ppm\x18\x06 \x01(\x03R\ftargetRtpPpm\x12'\n" + "\x0etarget_rtp_ppm\x18\x06 \x01(\x03R\ftargetRtpPpm\x12'\n" +
"\x0fexperience_pool\x18\a \x01(\tR\x0eexperiencePool\x12\x17\n" + "\x0fexperience_pool\x18\a \x01(\tR\x0eexperiencePool\x12\x17\n" +
"\apool_id\x18\b \x01(\tR\x06poolId\"\xc4\a\n" + "\apool_id\x18\b \x01(\tR\x06poolId\x12)\n" +
"\x10strategy_version\x18\t \x01(\tR\x0fstrategyVersion\"\xc4\a\n" +
"\x13LuckyGiftDrawResult\x12\x17\n" + "\x13LuckyGiftDrawResult\x12\x17\n" +
"\adraw_id\x18\x01 \x01(\tR\x06drawId\x12\x1d\n" + "\adraw_id\x18\x01 \x01(\tR\x06drawId\x12\x1d\n" +
"\n" + "\n" +
@ -2536,7 +3138,7 @@ const file_proto_luckygift_v1_luckygift_proto_rawDesc = "" +
"\vlucky_gifts\x18\x01 \x03(\v2!.hyapp.luckygift.v1.LuckyGiftMetaR\n" + "\vlucky_gifts\x18\x01 \x03(\v2!.hyapp.luckygift.v1.LuckyGiftMetaR\n" +
"luckyGifts\"f\n" + "luckyGifts\"f\n" +
"!BatchExecuteLuckyGiftDrawResponse\x12A\n" + "!BatchExecuteLuckyGiftDrawResponse\x12A\n" +
"\aresults\x18\x01 \x03(\v2'.hyapp.luckygift.v1.LuckyGiftDrawResultR\aresults\"\x8d\x03\n" + "\aresults\x18\x01 \x03(\v2'.hyapp.luckygift.v1.LuckyGiftDrawResultR\aresults\"\xaa\x03\n" +
"\x17ExternalGiftDrawRequest\x123\n" + "\x17ExternalGiftDrawRequest\x123\n" +
"\x04meta\x18\x01 \x01(\v2\x1f.hyapp.luckygift.v1.RequestMetaR\x04meta\x12\x19\n" + "\x04meta\x18\x01 \x01(\v2\x1f.hyapp.luckygift.v1.RequestMetaR\x04meta\x12\x19\n" +
"\bapp_code\x18\x03 \x01(\tR\aappCode\x12(\n" + "\bapp_code\x18\x03 \x01(\tR\aappCode\x12(\n" +
@ -2553,7 +3155,8 @@ const file_proto_luckygift_v1_luckygift_proto_rawDesc = "" +
"paid_at_ms\x18\n" + "paid_at_ms\x18\n" +
" \x01(\x03R\bpaidAtMs\x12#\n" + " \x01(\x03R\bpaidAtMs\x12#\n" +
"\rmetadata_json\x18\v \x01(\tR\fmetadataJson\x12\x17\n" + "\rmetadata_json\x18\v \x01(\tR\fmetadataJson\x12\x17\n" +
"\apool_id\x18\f \x01(\tR\x06poolId\"\xb2\x03\n" + "\apool_id\x18\f \x01(\tR\x06poolId\x12\x1b\n" +
"\tdevice_id\x18\r \x01(\tR\bdeviceId\"\xb2\x03\n" +
"\x18ExternalGiftDrawResponse\x12\x17\n" + "\x18ExternalGiftDrawResponse\x12\x17\n" +
"\adraw_id\x18\x01 \x01(\tR\x06drawId\x12\x1d\n" + "\adraw_id\x18\x01 \x01(\tR\x06drawId\x12\x1d\n" +
"\n" + "\n" +
@ -2614,7 +3217,7 @@ const file_proto_luckygift_v1_luckygift_proto_rawDesc = "" +
"\rpending_draws\x18\t \x01(\x03R\fpendingDraws\x12#\n" + "\rpending_draws\x18\t \x01(\x03R\fpendingDraws\x12#\n" +
"\rgranted_draws\x18\n" + "\rgranted_draws\x18\n" +
" \x01(\x03R\fgrantedDraws\x12!\n" + " \x01(\x03R\fgrantedDraws\x12!\n" +
"\ffailed_draws\x18\v \x01(\x03R\vfailedDraws\"\xb6\x02\n" + "\ffailed_draws\x18\v \x01(\x03R\vfailedDraws\"\xbf\x03\n" +
"\x14LuckyGiftPoolBalance\x12\x19\n" + "\x14LuckyGiftPoolBalance\x12\x19\n" +
"\bapp_code\x18\x01 \x01(\tR\aappCode\x12\x17\n" + "\bapp_code\x18\x01 \x01(\tR\aappCode\x12\x17\n" +
"\apool_id\x18\x02 \x01(\tR\x06poolId\x12\x18\n" + "\apool_id\x18\x02 \x01(\tR\x06poolId\x12\x18\n" +
@ -2624,7 +3227,11 @@ const file_proto_luckygift_v1_luckygift_proto_rawDesc = "" +
"\btotal_in\x18\x06 \x01(\x03R\atotalIn\x12\x1b\n" + "\btotal_in\x18\x06 \x01(\x03R\atotalIn\x12\x1b\n" +
"\ttotal_out\x18\a \x01(\x03R\btotalOut\x12\"\n" + "\ttotal_out\x18\a \x01(\x03R\btotalOut\x12\"\n" +
"\fmaterialized\x18\b \x01(\bR\fmaterialized\x12\"\n" + "\fmaterialized\x18\b \x01(\bR\fmaterialized\x12\"\n" +
"\rupdated_at_ms\x18\t \x01(\x03R\vupdatedAtMs\"\xa0\x02\n" + "\rupdated_at_ms\x18\t \x01(\x03R\vupdatedAtMs\x12)\n" +
"\x10strategy_version\x18\n" +
" \x01(\tR\x0fstrategyVersion\x12.\n" +
"\x13manual_credit_total\x18\v \x01(\x03R\x11manualCreditTotal\x12,\n" +
"\x12manual_debit_total\x18\f \x01(\x03R\x10manualDebitTotal\"\xa0\x02\n" +
"\x1eGetLuckyGiftDrawSummaryRequest\x123\n" + "\x1eGetLuckyGiftDrawSummaryRequest\x123\n" +
"\x04meta\x18\x01 \x01(\v2\x1f.hyapp.luckygift.v1.RequestMetaR\x04meta\x12\x17\n" + "\x04meta\x18\x01 \x01(\v2\x1f.hyapp.luckygift.v1.RequestMetaR\x04meta\x12\x17\n" +
"\agift_id\x18\x02 \x01(\tR\x06giftId\x12\x17\n" + "\agift_id\x18\x02 \x01(\tR\x06giftId\x12\x17\n" +
@ -2635,10 +3242,11 @@ const file_proto_luckygift_v1_luckygift_proto_rawDesc = "" +
"\x10external_user_id\x18\b \x01(\tR\x0eexternalUserId\x12#\n" + "\x10external_user_id\x18\b \x01(\tR\x0eexternalUserId\x12#\n" +
"\rexternal_only\x18\t \x01(\bR\fexternalOnly\"e\n" + "\rexternal_only\x18\t \x01(\bR\fexternalOnly\"e\n" +
"\x1fGetLuckyGiftDrawSummaryResponse\x12B\n" + "\x1fGetLuckyGiftDrawSummaryResponse\x12B\n" +
"\asummary\x18\x01 \x01(\v2(.hyapp.luckygift.v1.LuckyGiftDrawSummaryR\asummary\"p\n" + "\asummary\x18\x01 \x01(\v2(.hyapp.luckygift.v1.LuckyGiftDrawSummaryR\asummary\"\x9b\x01\n" +
" ListLuckyGiftPoolBalancesRequest\x123\n" + " ListLuckyGiftPoolBalancesRequest\x123\n" +
"\x04meta\x18\x01 \x01(\v2\x1f.hyapp.luckygift.v1.RequestMetaR\x04meta\x12\x17\n" + "\x04meta\x18\x01 \x01(\v2\x1f.hyapp.luckygift.v1.RequestMetaR\x04meta\x12\x17\n" +
"\apool_id\x18\x02 \x01(\tR\x06poolId\"c\n" + "\apool_id\x18\x02 \x01(\tR\x06poolId\x12)\n" +
"\x10strategy_version\x18\x03 \x01(\tR\x0fstrategyVersion\"c\n" +
"!ListLuckyGiftPoolBalancesResponse\x12>\n" + "!ListLuckyGiftPoolBalancesResponse\x12>\n" +
"\x05pools\x18\x01 \x03(\v2(.hyapp.luckygift.v1.LuckyGiftPoolBalanceR\x05pools\"\xba\x01\n" + "\x05pools\x18\x01 \x03(\v2(.hyapp.luckygift.v1.LuckyGiftPoolBalanceR\x05pools\"\xba\x01\n" +
"\fLuckyGiftHit\x12\x1d\n" + "\fLuckyGiftHit\x12\x1d\n" +
@ -2647,19 +3255,48 @@ const file_proto_luckygift_v1_luckygift_proto_rawDesc = "" +
"\adraw_id\x18\x02 \x01(\tR\x06drawId\x12(\n" + "\adraw_id\x18\x02 \x01(\tR\x06drawId\x12(\n" +
"\x10selected_tier_id\x18\x03 \x01(\tR\x0eselectedTierId\x12%\n" + "\x10selected_tier_id\x18\x03 \x01(\tR\x0eselectedTierId\x12%\n" +
"\x0emultiplier_ppm\x18\x04 \x01(\x03R\rmultiplierPpm\x12!\n" + "\x0emultiplier_ppm\x18\x04 \x01(\x03R\rmultiplierPpm\x12!\n" +
"\freward_coins\x18\x05 \x01(\x03R\vrewardCoins2\xf7\x03\n" + "\freward_coins\x18\x05 \x01(\x03R\vrewardCoins\"\xc6\x02\n" +
"!AdjustLuckyGiftPoolBalanceRequest\x123\n" +
"\x04meta\x18\x01 \x01(\v2\x1f.hyapp.luckygift.v1.RequestMetaR\x04meta\x12\x17\n" +
"\apool_id\x18\x02 \x01(\tR\x06poolId\x12)\n" +
"\x10strategy_version\x18\x03 \x01(\tR\x0fstrategyVersion\x12#\n" +
"\radjustment_id\x18\x04 \x01(\tR\fadjustmentId\x12\x1c\n" +
"\tdirection\x18\x05 \x01(\tR\tdirection\x12!\n" +
"\famount_coins\x18\x06 \x01(\x03R\vamountCoins\x12\x16\n" +
"\x06reason\x18\a \x01(\tR\x06reason\x12*\n" +
"\x11operator_admin_id\x18\b \x01(\x03R\x0foperatorAdminId\"\x92\x03\n" +
"\x17LuckyGiftPoolAdjustment\x12#\n" +
"\radjustment_id\x18\x01 \x01(\tR\fadjustmentId\x12\x19\n" +
"\bapp_code\x18\x02 \x01(\tR\aappCode\x12\x17\n" +
"\apool_id\x18\x03 \x01(\tR\x06poolId\x12)\n" +
"\x10strategy_version\x18\x04 \x01(\tR\x0fstrategyVersion\x12\x1c\n" +
"\tdirection\x18\x05 \x01(\tR\tdirection\x12!\n" +
"\famount_coins\x18\x06 \x01(\x03R\vamountCoins\x12\x16\n" +
"\x06reason\x18\a \x01(\tR\x06reason\x12*\n" +
"\x11operator_admin_id\x18\b \x01(\x03R\x0foperatorAdminId\x12%\n" +
"\x0ebalance_before\x18\t \x01(\x03R\rbalanceBefore\x12#\n" +
"\rbalance_after\x18\n" +
" \x01(\x03R\fbalanceAfter\x12\"\n" +
"\rcreated_at_ms\x18\v \x01(\x03R\vcreatedAtMs\"\xdc\x01\n" +
"\"AdjustLuckyGiftPoolBalanceResponse\x12K\n" +
"\n" +
"adjustment\x18\x01 \x01(\v2+.hyapp.luckygift.v1.LuckyGiftPoolAdjustmentR\n" +
"adjustment\x12<\n" +
"\x04pool\x18\x02 \x01(\v2(.hyapp.luckygift.v1.LuckyGiftPoolBalanceR\x04pool\x12+\n" +
"\x11idempotent_replay\x18\x03 \x01(\bR\x10idempotentReplay2\xf7\x03\n" +
"\x10LuckyGiftService\x12g\n" + "\x10LuckyGiftService\x12g\n" +
"\x0eCheckLuckyGift\x12).hyapp.luckygift.v1.CheckLuckyGiftRequest\x1a*.hyapp.luckygift.v1.CheckLuckyGiftResponse\x12y\n" + "\x0eCheckLuckyGift\x12).hyapp.luckygift.v1.CheckLuckyGiftRequest\x1a*.hyapp.luckygift.v1.CheckLuckyGiftResponse\x12y\n" +
"\x14ExecuteLuckyGiftDraw\x12/.hyapp.luckygift.v1.ExecuteLuckyGiftDrawRequest\x1a0.hyapp.luckygift.v1.ExecuteLuckyGiftDrawResponse\x12\x88\x01\n" + "\x14ExecuteLuckyGiftDraw\x12/.hyapp.luckygift.v1.ExecuteLuckyGiftDrawRequest\x1a0.hyapp.luckygift.v1.ExecuteLuckyGiftDrawResponse\x12\x88\x01\n" +
"\x19BatchExecuteLuckyGiftDraw\x124.hyapp.luckygift.v1.BatchExecuteLuckyGiftDrawRequest\x1a5.hyapp.luckygift.v1.BatchExecuteLuckyGiftDrawResponse\x12t\n" + "\x19BatchExecuteLuckyGiftDraw\x124.hyapp.luckygift.v1.BatchExecuteLuckyGiftDrawRequest\x1a5.hyapp.luckygift.v1.BatchExecuteLuckyGiftDrawResponse\x12t\n" +
"\x17ExecuteExternalGiftDraw\x12+.hyapp.luckygift.v1.ExternalGiftDrawRequest\x1a,.hyapp.luckygift.v1.ExternalGiftDrawResponse2\x8a\x06\n" + "\x17ExecuteExternalGiftDraw\x12+.hyapp.luckygift.v1.ExternalGiftDrawRequest\x1a,.hyapp.luckygift.v1.ExternalGiftDrawResponse2\x98\a\n" +
"\x15AdminLuckyGiftService\x12s\n" + "\x15AdminLuckyGiftService\x12s\n" +
"\x12GetLuckyGiftConfig\x12-.hyapp.luckygift.v1.GetLuckyGiftConfigRequest\x1a..hyapp.luckygift.v1.GetLuckyGiftConfigResponse\x12|\n" + "\x12GetLuckyGiftConfig\x12-.hyapp.luckygift.v1.GetLuckyGiftConfigRequest\x1a..hyapp.luckygift.v1.GetLuckyGiftConfigResponse\x12|\n" +
"\x15UpsertLuckyGiftConfig\x120.hyapp.luckygift.v1.UpsertLuckyGiftConfigRequest\x1a1.hyapp.luckygift.v1.UpsertLuckyGiftConfigResponse\x12y\n" + "\x15UpsertLuckyGiftConfig\x120.hyapp.luckygift.v1.UpsertLuckyGiftConfigRequest\x1a1.hyapp.luckygift.v1.UpsertLuckyGiftConfigResponse\x12y\n" +
"\x14ListLuckyGiftConfigs\x12/.hyapp.luckygift.v1.ListLuckyGiftConfigsRequest\x1a0.hyapp.luckygift.v1.ListLuckyGiftConfigsResponse\x12s\n" + "\x14ListLuckyGiftConfigs\x12/.hyapp.luckygift.v1.ListLuckyGiftConfigsRequest\x1a0.hyapp.luckygift.v1.ListLuckyGiftConfigsResponse\x12s\n" +
"\x12ListLuckyGiftDraws\x12-.hyapp.luckygift.v1.ListLuckyGiftDrawsRequest\x1a..hyapp.luckygift.v1.ListLuckyGiftDrawsResponse\x12\x82\x01\n" + "\x12ListLuckyGiftDraws\x12-.hyapp.luckygift.v1.ListLuckyGiftDrawsRequest\x1a..hyapp.luckygift.v1.ListLuckyGiftDrawsResponse\x12\x82\x01\n" +
"\x17GetLuckyGiftDrawSummary\x122.hyapp.luckygift.v1.GetLuckyGiftDrawSummaryRequest\x1a3.hyapp.luckygift.v1.GetLuckyGiftDrawSummaryResponse\x12\x88\x01\n" + "\x17GetLuckyGiftDrawSummary\x122.hyapp.luckygift.v1.GetLuckyGiftDrawSummaryRequest\x1a3.hyapp.luckygift.v1.GetLuckyGiftDrawSummaryResponse\x12\x88\x01\n" +
"\x19ListLuckyGiftPoolBalances\x124.hyapp.luckygift.v1.ListLuckyGiftPoolBalancesRequest\x1a5.hyapp.luckygift.v1.ListLuckyGiftPoolBalancesResponseB0Z.hyapp.local/api/proto/luckygift/v1;luckygiftv1b\x06proto3" "\x19ListLuckyGiftPoolBalances\x124.hyapp.luckygift.v1.ListLuckyGiftPoolBalancesRequest\x1a5.hyapp.luckygift.v1.ListLuckyGiftPoolBalancesResponse\x12\x8b\x01\n" +
"\x1aAdjustLuckyGiftPoolBalance\x125.hyapp.luckygift.v1.AdjustLuckyGiftPoolBalanceRequest\x1a6.hyapp.luckygift.v1.AdjustLuckyGiftPoolBalanceResponseB0Z.hyapp.local/api/proto/luckygift/v1;luckygiftv1b\x06proto3"
var ( var (
file_proto_luckygift_v1_luckygift_proto_rawDescOnce sync.Once file_proto_luckygift_v1_luckygift_proto_rawDescOnce sync.Once
@ -2673,7 +3310,7 @@ func file_proto_luckygift_v1_luckygift_proto_rawDescGZIP() []byte {
return file_proto_luckygift_v1_luckygift_proto_rawDescData 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{ var file_proto_luckygift_v1_luckygift_proto_goTypes = []any{
(*RequestMeta)(nil), // 0: hyapp.luckygift.v1.RequestMeta (*RequestMeta)(nil), // 0: hyapp.luckygift.v1.RequestMeta
(*LuckyGiftMeta)(nil), // 1: hyapp.luckygift.v1.LuckyGiftMeta (*LuckyGiftMeta)(nil), // 1: hyapp.luckygift.v1.LuckyGiftMeta
@ -2704,6 +3341,9 @@ var file_proto_luckygift_v1_luckygift_proto_goTypes = []any{
(*ListLuckyGiftPoolBalancesRequest)(nil), // 26: hyapp.luckygift.v1.ListLuckyGiftPoolBalancesRequest (*ListLuckyGiftPoolBalancesRequest)(nil), // 26: hyapp.luckygift.v1.ListLuckyGiftPoolBalancesRequest
(*ListLuckyGiftPoolBalancesResponse)(nil), // 27: hyapp.luckygift.v1.ListLuckyGiftPoolBalancesResponse (*ListLuckyGiftPoolBalancesResponse)(nil), // 27: hyapp.luckygift.v1.ListLuckyGiftPoolBalancesResponse
(*LuckyGiftHit)(nil), // 28: hyapp.luckygift.v1.LuckyGiftHit (*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{ var file_proto_luckygift_v1_luckygift_proto_depIdxs = []int32{
0, // 0: hyapp.luckygift.v1.LuckyGiftMeta.meta:type_name -> hyapp.luckygift.v1.RequestMeta 0, // 0: hyapp.luckygift.v1.LuckyGiftMeta.meta:type_name -> hyapp.luckygift.v1.RequestMeta
@ -2729,31 +3369,36 @@ var file_proto_luckygift_v1_luckygift_proto_depIdxs = []int32{
22, // 20: hyapp.luckygift.v1.GetLuckyGiftDrawSummaryResponse.summary:type_name -> hyapp.luckygift.v1.LuckyGiftDrawSummary 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 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 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 0, // 23: hyapp.luckygift.v1.AdjustLuckyGiftPoolBalanceRequest.meta:type_name -> hyapp.luckygift.v1.RequestMeta
8, // 24: hyapp.luckygift.v1.LuckyGiftService.ExecuteLuckyGiftDraw:input_type -> hyapp.luckygift.v1.ExecuteLuckyGiftDrawRequest 30, // 24: hyapp.luckygift.v1.AdjustLuckyGiftPoolBalanceResponse.adjustment:type_name -> hyapp.luckygift.v1.LuckyGiftPoolAdjustment
10, // 25: hyapp.luckygift.v1.LuckyGiftService.BatchExecuteLuckyGiftDraw:input_type -> hyapp.luckygift.v1.BatchExecuteLuckyGiftDrawRequest 23, // 25: hyapp.luckygift.v1.AdjustLuckyGiftPoolBalanceResponse.pool:type_name -> hyapp.luckygift.v1.LuckyGiftPoolBalance
12, // 26: hyapp.luckygift.v1.LuckyGiftService.ExecuteExternalGiftDraw:input_type -> hyapp.luckygift.v1.ExternalGiftDrawRequest 5, // 26: hyapp.luckygift.v1.LuckyGiftService.CheckLuckyGift:input_type -> hyapp.luckygift.v1.CheckLuckyGiftRequest
14, // 27: hyapp.luckygift.v1.AdminLuckyGiftService.GetLuckyGiftConfig:input_type -> hyapp.luckygift.v1.GetLuckyGiftConfigRequest 8, // 27: hyapp.luckygift.v1.LuckyGiftService.ExecuteLuckyGiftDraw:input_type -> hyapp.luckygift.v1.ExecuteLuckyGiftDrawRequest
16, // 28: hyapp.luckygift.v1.AdminLuckyGiftService.UpsertLuckyGiftConfig:input_type -> hyapp.luckygift.v1.UpsertLuckyGiftConfigRequest 10, // 28: hyapp.luckygift.v1.LuckyGiftService.BatchExecuteLuckyGiftDraw:input_type -> hyapp.luckygift.v1.BatchExecuteLuckyGiftDrawRequest
18, // 29: hyapp.luckygift.v1.AdminLuckyGiftService.ListLuckyGiftConfigs:input_type -> hyapp.luckygift.v1.ListLuckyGiftConfigsRequest 12, // 29: hyapp.luckygift.v1.LuckyGiftService.ExecuteExternalGiftDraw:input_type -> hyapp.luckygift.v1.ExternalGiftDrawRequest
20, // 30: hyapp.luckygift.v1.AdminLuckyGiftService.ListLuckyGiftDraws:input_type -> hyapp.luckygift.v1.ListLuckyGiftDrawsRequest 14, // 30: hyapp.luckygift.v1.AdminLuckyGiftService.GetLuckyGiftConfig:input_type -> hyapp.luckygift.v1.GetLuckyGiftConfigRequest
24, // 31: hyapp.luckygift.v1.AdminLuckyGiftService.GetLuckyGiftDrawSummary:input_type -> hyapp.luckygift.v1.GetLuckyGiftDrawSummaryRequest 16, // 31: hyapp.luckygift.v1.AdminLuckyGiftService.UpsertLuckyGiftConfig:input_type -> hyapp.luckygift.v1.UpsertLuckyGiftConfigRequest
26, // 32: hyapp.luckygift.v1.AdminLuckyGiftService.ListLuckyGiftPoolBalances:input_type -> hyapp.luckygift.v1.ListLuckyGiftPoolBalancesRequest 18, // 32: hyapp.luckygift.v1.AdminLuckyGiftService.ListLuckyGiftConfigs:input_type -> hyapp.luckygift.v1.ListLuckyGiftConfigsRequest
6, // 33: hyapp.luckygift.v1.LuckyGiftService.CheckLuckyGift:output_type -> hyapp.luckygift.v1.CheckLuckyGiftResponse 20, // 33: hyapp.luckygift.v1.AdminLuckyGiftService.ListLuckyGiftDraws:input_type -> hyapp.luckygift.v1.ListLuckyGiftDrawsRequest
9, // 34: hyapp.luckygift.v1.LuckyGiftService.ExecuteLuckyGiftDraw:output_type -> hyapp.luckygift.v1.ExecuteLuckyGiftDrawResponse 24, // 34: hyapp.luckygift.v1.AdminLuckyGiftService.GetLuckyGiftDrawSummary:input_type -> hyapp.luckygift.v1.GetLuckyGiftDrawSummaryRequest
11, // 35: hyapp.luckygift.v1.LuckyGiftService.BatchExecuteLuckyGiftDraw:output_type -> hyapp.luckygift.v1.BatchExecuteLuckyGiftDrawResponse 26, // 35: hyapp.luckygift.v1.AdminLuckyGiftService.ListLuckyGiftPoolBalances:input_type -> hyapp.luckygift.v1.ListLuckyGiftPoolBalancesRequest
13, // 36: hyapp.luckygift.v1.LuckyGiftService.ExecuteExternalGiftDraw:output_type -> hyapp.luckygift.v1.ExternalGiftDrawResponse 29, // 36: hyapp.luckygift.v1.AdminLuckyGiftService.AdjustLuckyGiftPoolBalance:input_type -> hyapp.luckygift.v1.AdjustLuckyGiftPoolBalanceRequest
15, // 37: hyapp.luckygift.v1.AdminLuckyGiftService.GetLuckyGiftConfig:output_type -> hyapp.luckygift.v1.GetLuckyGiftConfigResponse 6, // 37: hyapp.luckygift.v1.LuckyGiftService.CheckLuckyGift:output_type -> hyapp.luckygift.v1.CheckLuckyGiftResponse
17, // 38: hyapp.luckygift.v1.AdminLuckyGiftService.UpsertLuckyGiftConfig:output_type -> hyapp.luckygift.v1.UpsertLuckyGiftConfigResponse 9, // 38: hyapp.luckygift.v1.LuckyGiftService.ExecuteLuckyGiftDraw:output_type -> hyapp.luckygift.v1.ExecuteLuckyGiftDrawResponse
19, // 39: hyapp.luckygift.v1.AdminLuckyGiftService.ListLuckyGiftConfigs:output_type -> hyapp.luckygift.v1.ListLuckyGiftConfigsResponse 11, // 39: hyapp.luckygift.v1.LuckyGiftService.BatchExecuteLuckyGiftDraw:output_type -> hyapp.luckygift.v1.BatchExecuteLuckyGiftDrawResponse
21, // 40: hyapp.luckygift.v1.AdminLuckyGiftService.ListLuckyGiftDraws:output_type -> hyapp.luckygift.v1.ListLuckyGiftDrawsResponse 13, // 40: hyapp.luckygift.v1.LuckyGiftService.ExecuteExternalGiftDraw:output_type -> hyapp.luckygift.v1.ExternalGiftDrawResponse
25, // 41: hyapp.luckygift.v1.AdminLuckyGiftService.GetLuckyGiftDrawSummary:output_type -> hyapp.luckygift.v1.GetLuckyGiftDrawSummaryResponse 15, // 41: hyapp.luckygift.v1.AdminLuckyGiftService.GetLuckyGiftConfig:output_type -> hyapp.luckygift.v1.GetLuckyGiftConfigResponse
27, // 42: hyapp.luckygift.v1.AdminLuckyGiftService.ListLuckyGiftPoolBalances:output_type -> hyapp.luckygift.v1.ListLuckyGiftPoolBalancesResponse 17, // 42: hyapp.luckygift.v1.AdminLuckyGiftService.UpsertLuckyGiftConfig:output_type -> hyapp.luckygift.v1.UpsertLuckyGiftConfigResponse
33, // [33:43] is the sub-list for method output_type 19, // 43: hyapp.luckygift.v1.AdminLuckyGiftService.ListLuckyGiftConfigs:output_type -> hyapp.luckygift.v1.ListLuckyGiftConfigsResponse
23, // [23:33] is the sub-list for method input_type 21, // 44: hyapp.luckygift.v1.AdminLuckyGiftService.ListLuckyGiftDraws:output_type -> hyapp.luckygift.v1.ListLuckyGiftDrawsResponse
23, // [23:23] is the sub-list for extension type_name 25, // 45: hyapp.luckygift.v1.AdminLuckyGiftService.GetLuckyGiftDrawSummary:output_type -> hyapp.luckygift.v1.GetLuckyGiftDrawSummaryResponse
23, // [23:23] is the sub-list for extension extendee 27, // 46: hyapp.luckygift.v1.AdminLuckyGiftService.ListLuckyGiftPoolBalances:output_type -> hyapp.luckygift.v1.ListLuckyGiftPoolBalancesResponse
0, // [0:23] is the sub-list for field type_name 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() } func init() { file_proto_luckygift_v1_luckygift_proto_init() }
@ -2767,7 +3412,7 @@ func file_proto_luckygift_v1_luckygift_proto_init() {
GoPackagePath: reflect.TypeOf(x{}).PkgPath(), GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
RawDescriptor: unsafe.Slice(unsafe.StringData(file_proto_luckygift_v1_luckygift_proto_rawDesc), len(file_proto_luckygift_v1_luckygift_proto_rawDesc)), RawDescriptor: unsafe.Slice(unsafe.StringData(file_proto_luckygift_v1_luckygift_proto_rawDesc), len(file_proto_luckygift_v1_luckygift_proto_rawDesc)),
NumEnums: 0, NumEnums: 0,
NumMessages: 29, NumMessages: 32,
NumExtensions: 0, NumExtensions: 0,
NumServices: 2, NumServices: 2,
}, },

View File

@ -36,6 +36,12 @@ message LuckyGiftMeta {
string sender_avatar = 16; string sender_avatar = 16;
string sender_display_user_id = 17; string sender_display_user_id = 17;
string sender_pretty_display_user_id = 18; string sender_pretty_display_user_id = 18;
//
int64 recharge_7d_coins = 19;
int64 recharge_30d_coins = 20;
int64 last_recharged_at_ms = 21;
// gift_income_coins wallet dynamic_v3
int64 gift_income_coins = 22;
} }
message LuckyGiftRuleTier { message LuckyGiftRuleTier {
@ -50,6 +56,9 @@ message LuckyGiftRuleTier {
message LuckyGiftRuleStage { message LuckyGiftRuleStage {
string stage = 1; string stage = 1;
repeated LuckyGiftRuleTier tiers = 2; repeated LuckyGiftRuleTier tiers = 2;
// dynamic_v3 novice 0/0
int64 min_recharge_7d_coins = 3;
int64 min_recharge_30d_coins = 4;
} }
message LuckyGiftRuleConfig { message LuckyGiftRuleConfig {
@ -68,6 +77,30 @@ message LuckyGiftRuleConfig {
int64 created_by_admin_id = 13; int64 created_by_admin_id = 13;
int64 created_at_ms = 14; int64 created_at_ms = 14;
repeated LuckyGiftRuleStage stages = 15; repeated LuckyGiftRuleStage stages = 15;
// strategy_version fixed_v2 dynamic_v3
string strategy_version = 16;
int64 profit_rate_ppm = 17;
int64 anchor_rate_ppm = 18;
int64 initial_pool_coins = 19;
int64 loss_streak_guarantee = 20;
int64 low_watermark_coins = 21;
int64 low_water_nonzero_factor_ppm = 22;
int64 high_watermark_coins = 23;
int64 high_water_nonzero_factor_ppm = 24;
int64 recharge_boost_window_ms = 25;
int64 recharge_boost_factor_ppm = 26;
repeated int64 jackpot_multiplier_ppms = 27;
int64 jackpot_global_rtp_max_ppm = 28;
int64 jackpot_user_day_rtp_max_ppm = 29;
int64 jackpot_user_72h_rtp_max_ppm = 30;
int64 jackpot_spend_threshold_coins = 31;
int64 max_jackpot_hits_per_user_day = 32;
int64 max_single_payout = 33;
int64 user_hourly_payout_cap = 34;
int64 user_daily_payout_cap = 35;
int64 device_daily_payout_cap = 36;
int64 room_hourly_payout_cap = 37;
int64 anchor_daily_payout_cap = 38;
} }
message CheckLuckyGiftRequest { message CheckLuckyGiftRequest {
@ -87,6 +120,8 @@ message CheckLuckyGiftResponse {
int64 target_rtp_ppm = 6; int64 target_rtp_ppm = 6;
string experience_pool = 7; string experience_pool = 7;
string pool_id = 8; string pool_id = 8;
// strategy_version lucky-gift owner room saga fixed_v2 dynamic_v3
string strategy_version = 9;
} }
message LuckyGiftDrawResult { message LuckyGiftDrawResult {
@ -146,6 +181,8 @@ message ExternalGiftDrawRequest {
int64 paid_at_ms = 10; int64 paid_at_ms = 10;
string metadata_json = 11; string metadata_json = 11;
string pool_id = 12; string pool_id = 12;
// dynamic_v3 luck-gateway /fixed_v2
string device_id = 13;
} }
message ExternalGiftDrawResponse { message ExternalGiftDrawResponse {
@ -232,6 +269,9 @@ message LuckyGiftPoolBalance {
int64 total_out = 7; int64 total_out = 7;
bool materialized = 8; bool materialized = 8;
int64 updated_at_ms = 9; int64 updated_at_ms = 9;
string strategy_version = 10;
int64 manual_credit_total = 11;
int64 manual_debit_total = 12;
} }
message GetLuckyGiftDrawSummaryRequest { message GetLuckyGiftDrawSummaryRequest {
@ -252,6 +292,7 @@ message GetLuckyGiftDrawSummaryResponse {
message ListLuckyGiftPoolBalancesRequest { message ListLuckyGiftPoolBalancesRequest {
RequestMeta meta = 1; RequestMeta meta = 1;
string pool_id = 2; string pool_id = 2;
string strategy_version = 3;
} }
message ListLuckyGiftPoolBalancesResponse { message ListLuckyGiftPoolBalancesResponse {
@ -272,6 +313,7 @@ service AdminLuckyGiftService {
rpc ListLuckyGiftDraws(ListLuckyGiftDrawsRequest) returns (ListLuckyGiftDrawsResponse); rpc ListLuckyGiftDraws(ListLuckyGiftDrawsRequest) returns (ListLuckyGiftDrawsResponse);
rpc GetLuckyGiftDrawSummary(GetLuckyGiftDrawSummaryRequest) returns (GetLuckyGiftDrawSummaryResponse); rpc GetLuckyGiftDrawSummary(GetLuckyGiftDrawSummaryRequest) returns (GetLuckyGiftDrawSummaryResponse);
rpc ListLuckyGiftPoolBalances(ListLuckyGiftPoolBalancesRequest) returns (ListLuckyGiftPoolBalancesResponse); rpc ListLuckyGiftPoolBalances(ListLuckyGiftPoolBalancesRequest) returns (ListLuckyGiftPoolBalancesResponse);
rpc AdjustLuckyGiftPoolBalance(AdjustLuckyGiftPoolBalanceRequest) returns (AdjustLuckyGiftPoolBalanceResponse);
} }
// LuckyGiftHit // LuckyGiftHit
@ -283,3 +325,36 @@ message LuckyGiftHit {
int64 multiplier_ppm = 4; int64 multiplier_ppm = 4;
int64 reward_coins = 5; 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

@ -241,6 +241,7 @@ const (
AdminLuckyGiftService_ListLuckyGiftDraws_FullMethodName = "/hyapp.luckygift.v1.AdminLuckyGiftService/ListLuckyGiftDraws" AdminLuckyGiftService_ListLuckyGiftDraws_FullMethodName = "/hyapp.luckygift.v1.AdminLuckyGiftService/ListLuckyGiftDraws"
AdminLuckyGiftService_GetLuckyGiftDrawSummary_FullMethodName = "/hyapp.luckygift.v1.AdminLuckyGiftService/GetLuckyGiftDrawSummary" AdminLuckyGiftService_GetLuckyGiftDrawSummary_FullMethodName = "/hyapp.luckygift.v1.AdminLuckyGiftService/GetLuckyGiftDrawSummary"
AdminLuckyGiftService_ListLuckyGiftPoolBalances_FullMethodName = "/hyapp.luckygift.v1.AdminLuckyGiftService/ListLuckyGiftPoolBalances" AdminLuckyGiftService_ListLuckyGiftPoolBalances_FullMethodName = "/hyapp.luckygift.v1.AdminLuckyGiftService/ListLuckyGiftPoolBalances"
AdminLuckyGiftService_AdjustLuckyGiftPoolBalance_FullMethodName = "/hyapp.luckygift.v1.AdminLuckyGiftService/AdjustLuckyGiftPoolBalance"
) )
// AdminLuckyGiftServiceClient is the client API for AdminLuckyGiftService service. // 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) ListLuckyGiftDraws(ctx context.Context, in *ListLuckyGiftDrawsRequest, opts ...grpc.CallOption) (*ListLuckyGiftDrawsResponse, error)
GetLuckyGiftDrawSummary(ctx context.Context, in *GetLuckyGiftDrawSummaryRequest, opts ...grpc.CallOption) (*GetLuckyGiftDrawSummaryResponse, error) GetLuckyGiftDrawSummary(ctx context.Context, in *GetLuckyGiftDrawSummaryRequest, opts ...grpc.CallOption) (*GetLuckyGiftDrawSummaryResponse, error)
ListLuckyGiftPoolBalances(ctx context.Context, in *ListLuckyGiftPoolBalancesRequest, opts ...grpc.CallOption) (*ListLuckyGiftPoolBalancesResponse, 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 { type adminLuckyGiftServiceClient struct {
@ -323,6 +325,16 @@ func (c *adminLuckyGiftServiceClient) ListLuckyGiftPoolBalances(ctx context.Cont
return out, nil 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. // AdminLuckyGiftServiceServer is the server API for AdminLuckyGiftService service.
// All implementations must embed UnimplementedAdminLuckyGiftServiceServer // All implementations must embed UnimplementedAdminLuckyGiftServiceServer
// for forward compatibility. // for forward compatibility.
@ -333,6 +345,7 @@ type AdminLuckyGiftServiceServer interface {
ListLuckyGiftDraws(context.Context, *ListLuckyGiftDrawsRequest) (*ListLuckyGiftDrawsResponse, error) ListLuckyGiftDraws(context.Context, *ListLuckyGiftDrawsRequest) (*ListLuckyGiftDrawsResponse, error)
GetLuckyGiftDrawSummary(context.Context, *GetLuckyGiftDrawSummaryRequest) (*GetLuckyGiftDrawSummaryResponse, error) GetLuckyGiftDrawSummary(context.Context, *GetLuckyGiftDrawSummaryRequest) (*GetLuckyGiftDrawSummaryResponse, error)
ListLuckyGiftPoolBalances(context.Context, *ListLuckyGiftPoolBalancesRequest) (*ListLuckyGiftPoolBalancesResponse, error) ListLuckyGiftPoolBalances(context.Context, *ListLuckyGiftPoolBalancesRequest) (*ListLuckyGiftPoolBalancesResponse, error)
AdjustLuckyGiftPoolBalance(context.Context, *AdjustLuckyGiftPoolBalanceRequest) (*AdjustLuckyGiftPoolBalanceResponse, error)
mustEmbedUnimplementedAdminLuckyGiftServiceServer() mustEmbedUnimplementedAdminLuckyGiftServiceServer()
} }
@ -361,6 +374,9 @@ func (UnimplementedAdminLuckyGiftServiceServer) GetLuckyGiftDrawSummary(context.
func (UnimplementedAdminLuckyGiftServiceServer) ListLuckyGiftPoolBalances(context.Context, *ListLuckyGiftPoolBalancesRequest) (*ListLuckyGiftPoolBalancesResponse, error) { func (UnimplementedAdminLuckyGiftServiceServer) ListLuckyGiftPoolBalances(context.Context, *ListLuckyGiftPoolBalancesRequest) (*ListLuckyGiftPoolBalancesResponse, error) {
return nil, status.Error(codes.Unimplemented, "method ListLuckyGiftPoolBalances not implemented") 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) mustEmbedUnimplementedAdminLuckyGiftServiceServer() {}
func (UnimplementedAdminLuckyGiftServiceServer) testEmbeddedByValue() {} func (UnimplementedAdminLuckyGiftServiceServer) testEmbeddedByValue() {}
@ -490,6 +506,24 @@ func _AdminLuckyGiftService_ListLuckyGiftPoolBalances_Handler(srv interface{}, c
return interceptor(ctx, in, info, handler) 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. // AdminLuckyGiftService_ServiceDesc is the grpc.ServiceDesc for AdminLuckyGiftService service.
// It's only intended for direct use with grpc.RegisterService, // It's only intended for direct use with grpc.RegisterService,
// and not to be introspected or modified (even as a copy) // and not to be introspected or modified (even as a copy)
@ -521,6 +555,10 @@ var AdminLuckyGiftService_ServiceDesc = grpc.ServiceDesc{
MethodName: "ListLuckyGiftPoolBalances", MethodName: "ListLuckyGiftPoolBalances",
Handler: _AdminLuckyGiftService_ListLuckyGiftPoolBalances_Handler, Handler: _AdminLuckyGiftService_ListLuckyGiftPoolBalances_Handler,
}, },
{
MethodName: "AdjustLuckyGiftPoolBalance",
Handler: _AdminLuckyGiftService_AdjustLuckyGiftPoolBalance_Handler,
},
}, },
Streams: []grpc.StreamDesc{}, Streams: []grpc.StreamDesc{},
Metadata: "proto/luckygift/v1/luckygift.proto", Metadata: "proto/luckygift/v1/luckygift.proto",

View File

@ -35,6 +35,8 @@ type RequestMeta struct {
SessionId string `protobuf:"bytes,6,opt,name=session_id,json=sessionId,proto3" json:"session_id,omitempty"` SessionId string `protobuf:"bytes,6,opt,name=session_id,json=sessionId,proto3" json:"session_id,omitempty"`
SentAtMs int64 `protobuf:"varint,7,opt,name=sent_at_ms,json=sentAtMs,proto3" json:"sent_at_ms,omitempty"` SentAtMs int64 `protobuf:"varint,7,opt,name=sent_at_ms,json=sentAtMs,proto3" json:"sent_at_ms,omitempty"`
AppCode string `protobuf:"bytes,8,opt,name=app_code,json=appCode,proto3" json:"app_code,omitempty"` AppCode string `protobuf:"bytes,8,opt,name=app_code,json=appCode,proto3" json:"app_code,omitempty"`
// device_id 只能来自 gateway 已验签 access JWT 的同名 claim旧 JWT 缺字段时保持空值,不得用 session_id/command_id 伪造。
DeviceId string `protobuf:"bytes,9,opt,name=device_id,json=deviceId,proto3" json:"device_id,omitempty"`
unknownFields protoimpl.UnknownFields unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache sizeCache protoimpl.SizeCache
} }
@ -125,6 +127,13 @@ func (x *RequestMeta) GetAppCode() string {
return "" return ""
} }
func (x *RequestMeta) GetDeviceId() string {
if x != nil {
return x.DeviceId
}
return ""
}
// CommandResult 统一返回命令是否真正落地,以及落地后的房间版本。 // CommandResult 统一返回命令是否真正落地,以及落地后的房间版本。
type CommandResult struct { type CommandResult struct {
state protoimpl.MessageState `protogen:"open.v1"` state protoimpl.MessageState `protogen:"open.v1"`
@ -12594,7 +12603,7 @@ var File_proto_room_v1_room_proto protoreflect.FileDescriptor
const file_proto_room_v1_room_proto_rawDesc = "" + const file_proto_room_v1_room_proto_rawDesc = "" +
"\n" + "\n" +
"\x18proto/room/v1/room.proto\x12\rhyapp.room.v1\"\x88\x02\n" + "\x18proto/room/v1/room.proto\x12\rhyapp.room.v1\"\xa5\x02\n" +
"\vRequestMeta\x12\x1d\n" + "\vRequestMeta\x12\x1d\n" +
"\n" + "\n" +
"request_id\x18\x01 \x01(\tR\trequestId\x12\x1d\n" + "request_id\x18\x01 \x01(\tR\trequestId\x12\x1d\n" +
@ -12607,7 +12616,8 @@ const file_proto_room_v1_room_proto_rawDesc = "" +
"session_id\x18\x06 \x01(\tR\tsessionId\x12\x1c\n" + "session_id\x18\x06 \x01(\tR\tsessionId\x12\x1c\n" +
"\n" + "\n" +
"sent_at_ms\x18\a \x01(\x03R\bsentAtMs\x12\x19\n" + "sent_at_ms\x18\a \x01(\x03R\bsentAtMs\x12\x19\n" +
"\bapp_code\x18\b \x01(\tR\aappCode\"r\n" + "\bapp_code\x18\b \x01(\tR\aappCode\x12\x1b\n" +
"\tdevice_id\x18\t \x01(\tR\bdeviceId\"r\n" +
"\rCommandResult\x12\x18\n" + "\rCommandResult\x12\x18\n" +
"\aapplied\x18\x01 \x01(\bR\aapplied\x12!\n" + "\aapplied\x18\x01 \x01(\bR\aapplied\x12!\n" +
"\froom_version\x18\x02 \x01(\x03R\vroomVersion\x12$\n" + "\froom_version\x18\x02 \x01(\x03R\vroomVersion\x12$\n" +

View File

@ -17,6 +17,8 @@ message RequestMeta {
string session_id = 6; string session_id = 6;
int64 sent_at_ms = 7; int64 sent_at_ms = 7;
string app_code = 8; string app_code = 8;
// device_id gateway access JWT claim JWT session_id/command_id
string device_id = 9;
} }
// CommandResult // CommandResult

View File

@ -13259,6 +13259,95 @@ func (x *ListCPFormationGiftFeedResponse) GetItems() []*CPFormationGiftFeedItem
return nil return nil
} }
// ListAppsRequest 由内部调度器读取启用租户;不接受租户过滤,避免后台任务再次退化成单 App。
type ListAppsRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
Meta *RequestMeta `protobuf:"bytes,1,opt,name=meta,proto3" json:"meta,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *ListAppsRequest) Reset() {
*x = ListAppsRequest{}
mi := &file_proto_user_v1_user_proto_msgTypes[180]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *ListAppsRequest) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*ListAppsRequest) ProtoMessage() {}
func (x *ListAppsRequest) ProtoReflect() protoreflect.Message {
mi := &file_proto_user_v1_user_proto_msgTypes[180]
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 ListAppsRequest.ProtoReflect.Descriptor instead.
func (*ListAppsRequest) Descriptor() ([]byte, []int) {
return file_proto_user_v1_user_proto_rawDescGZIP(), []int{180}
}
func (x *ListAppsRequest) GetMeta() *RequestMeta {
if x != nil {
return x.Meta
}
return nil
}
type ListAppsResponse struct {
state protoimpl.MessageState `protogen:"open.v1"`
Apps []*App `protobuf:"bytes,1,rep,name=apps,proto3" json:"apps,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *ListAppsResponse) Reset() {
*x = ListAppsResponse{}
mi := &file_proto_user_v1_user_proto_msgTypes[181]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *ListAppsResponse) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*ListAppsResponse) ProtoMessage() {}
func (x *ListAppsResponse) ProtoReflect() protoreflect.Message {
mi := &file_proto_user_v1_user_proto_msgTypes[181]
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 ListAppsResponse.ProtoReflect.Descriptor instead.
func (*ListAppsResponse) Descriptor() ([]byte, []int) {
return file_proto_user_v1_user_proto_rawDescGZIP(), []int{181}
}
func (x *ListAppsResponse) GetApps() []*App {
if x != nil {
return x.Apps
}
return nil
}
var File_proto_user_v1_user_proto protoreflect.FileDescriptor var File_proto_user_v1_user_proto protoreflect.FileDescriptor
const file_proto_user_v1_user_proto_rawDesc = "" + const file_proto_user_v1_user_proto_rawDesc = "" +
@ -14384,7 +14473,11 @@ const file_proto_user_v1_user_proto_rawDesc = "" +
"\x04meta\x18\x01 \x01(\v2\x1a.hyapp.user.v1.RequestMetaR\x04meta\x12\x14\n" + "\x04meta\x18\x01 \x01(\v2\x1a.hyapp.user.v1.RequestMetaR\x04meta\x12\x14\n" +
"\x05limit\x18\x02 \x01(\x05R\x05limit\"_\n" + "\x05limit\x18\x02 \x01(\x05R\x05limit\"_\n" +
"\x1fListCPFormationGiftFeedResponse\x12<\n" + "\x1fListCPFormationGiftFeedResponse\x12<\n" +
"\x05items\x18\x01 \x03(\v2&.hyapp.user.v1.CPFormationGiftFeedItemR\x05items*s\n" + "\x05items\x18\x01 \x03(\v2&.hyapp.user.v1.CPFormationGiftFeedItemR\x05items\"A\n" +
"\x0fListAppsRequest\x12.\n" +
"\x04meta\x18\x01 \x01(\v2\x1a.hyapp.user.v1.RequestMetaR\x04meta\":\n" +
"\x10ListAppsResponse\x12&\n" +
"\x04apps\x18\x01 \x03(\v2\x12.hyapp.user.v1.AppR\x04apps*s\n" +
"\n" + "\n" +
"UserStatus\x12\x1b\n" + "UserStatus\x12\x1b\n" +
"\x17USER_STATUS_UNSPECIFIED\x10\x00\x12\x16\n" + "\x17USER_STATUS_UNSPECIFIED\x10\x00\x12\x16\n" +
@ -14453,10 +14546,11 @@ const file_proto_user_v1_user_proto_rawDesc = "" +
"\x1cRefreshCPIntimacyLeaderboard\x12\x1f.hyapp.user.v1.CronBatchRequest\x1a .hyapp.user.v1.CronBatchResponse2\xd1\x01\n" + "\x1cRefreshCPIntimacyLeaderboard\x12\x1f.hyapp.user.v1.CronBatchRequest\x1a .hyapp.user.v1.CronBatchResponse2\xd1\x01\n" +
"\x11UserDeviceService\x12Z\n" + "\x11UserDeviceService\x12Z\n" +
"\rBindPushToken\x12#.hyapp.user.v1.BindPushTokenRequest\x1a$.hyapp.user.v1.BindPushTokenResponse\x12`\n" + "\rBindPushToken\x12#.hyapp.user.v1.BindPushTokenRequest\x1a$.hyapp.user.v1.BindPushTokenResponse\x12`\n" +
"\x0fDeletePushToken\x12%.hyapp.user.v1.DeletePushTokenRequest\x1a&.hyapp.user.v1.DeletePushTokenResponse2g\n" + "\x0fDeletePushToken\x12%.hyapp.user.v1.DeletePushTokenRequest\x1a&.hyapp.user.v1.DeletePushTokenResponse2\xb4\x01\n" +
"\x12AppRegistryService\x12Q\n" + "\x12AppRegistryService\x12Q\n" +
"\n" + "\n" +
"ResolveApp\x12 .hyapp.user.v1.ResolveAppRequest\x1a!.hyapp.user.v1.ResolveAppResponse2\xc7\x01\n" + "ResolveApp\x12 .hyapp.user.v1.ResolveAppRequest\x1a!.hyapp.user.v1.ResolveAppResponse\x12K\n" +
"\bListApps\x12\x1e.hyapp.user.v1.ListAppsRequest\x1a\x1f.hyapp.user.v1.ListAppsResponse2\xc7\x01\n" +
"\x13CountryAdminService\x12Z\n" + "\x13CountryAdminService\x12Z\n" +
"\rListCountries\x12#.hyapp.user.v1.ListCountriesRequest\x1a$.hyapp.user.v1.ListCountriesResponse\x12T\n" + "\rListCountries\x12#.hyapp.user.v1.ListCountriesRequest\x1a$.hyapp.user.v1.ListCountriesResponse\x12T\n" +
"\rUpdateCountry\x12#.hyapp.user.v1.UpdateCountryRequest\x1a\x1e.hyapp.user.v1.CountryResponse2\xa2\x02\n" + "\rUpdateCountry\x12#.hyapp.user.v1.UpdateCountryRequest\x1a\x1e.hyapp.user.v1.CountryResponse2\xa2\x02\n" +
@ -14499,7 +14593,7 @@ func file_proto_user_v1_user_proto_rawDescGZIP() []byte {
} }
var file_proto_user_v1_user_proto_enumTypes = make([]protoimpl.EnumInfo, 1) var file_proto_user_v1_user_proto_enumTypes = make([]protoimpl.EnumInfo, 1)
var file_proto_user_v1_user_proto_msgTypes = make([]protoimpl.MessageInfo, 183) var file_proto_user_v1_user_proto_msgTypes = make([]protoimpl.MessageInfo, 185)
var file_proto_user_v1_user_proto_goTypes = []any{ var file_proto_user_v1_user_proto_goTypes = []any{
(UserStatus)(0), // 0: hyapp.user.v1.UserStatus (UserStatus)(0), // 0: hyapp.user.v1.UserStatus
(*RequestMeta)(nil), // 1: hyapp.user.v1.RequestMeta (*RequestMeta)(nil), // 1: hyapp.user.v1.RequestMeta
@ -14682,9 +14776,11 @@ var file_proto_user_v1_user_proto_goTypes = []any{
(*CPFormationGiftFeedItem)(nil), // 178: hyapp.user.v1.CPFormationGiftFeedItem (*CPFormationGiftFeedItem)(nil), // 178: hyapp.user.v1.CPFormationGiftFeedItem
(*ListCPFormationGiftFeedRequest)(nil), // 179: hyapp.user.v1.ListCPFormationGiftFeedRequest (*ListCPFormationGiftFeedRequest)(nil), // 179: hyapp.user.v1.ListCPFormationGiftFeedRequest
(*ListCPFormationGiftFeedResponse)(nil), // 180: hyapp.user.v1.ListCPFormationGiftFeedResponse (*ListCPFormationGiftFeedResponse)(nil), // 180: hyapp.user.v1.ListCPFormationGiftFeedResponse
nil, // 181: hyapp.user.v1.BatchGetUsersResponse.UsersEntry (*ListAppsRequest)(nil), // 181: hyapp.user.v1.ListAppsRequest
nil, // 182: hyapp.user.v1.BatchGetUserAdminProfilesResponse.ProfilesEntry (*ListAppsResponse)(nil), // 182: hyapp.user.v1.ListAppsResponse
nil, // 183: hyapp.user.v1.BatchGetRoomBasicUsersResponse.UsersEntry nil, // 183: hyapp.user.v1.BatchGetUsersResponse.UsersEntry
nil, // 184: hyapp.user.v1.BatchGetUserAdminProfilesResponse.ProfilesEntry
nil, // 185: hyapp.user.v1.BatchGetRoomBasicUsersResponse.UsersEntry
} }
var file_proto_user_v1_user_proto_depIdxs = []int32{ var file_proto_user_v1_user_proto_depIdxs = []int32{
1, // 0: hyapp.user.v1.ResolveAppRequest.meta:type_name -> hyapp.user.v1.RequestMeta 1, // 0: hyapp.user.v1.ResolveAppRequest.meta:type_name -> hyapp.user.v1.RequestMeta
@ -14758,15 +14854,15 @@ var file_proto_user_v1_user_proto_depIdxs = []int32{
1, // 68: hyapp.user.v1.SubmitReportRequest.meta:type_name -> hyapp.user.v1.RequestMeta 1, // 68: hyapp.user.v1.SubmitReportRequest.meta:type_name -> hyapp.user.v1.RequestMeta
78, // 69: hyapp.user.v1.SubmitReportResponse.report:type_name -> hyapp.user.v1.UserReport 78, // 69: hyapp.user.v1.SubmitReportResponse.report:type_name -> hyapp.user.v1.UserReport
1, // 70: hyapp.user.v1.BatchGetUsersRequest.meta:type_name -> hyapp.user.v1.RequestMeta 1, // 70: hyapp.user.v1.BatchGetUsersRequest.meta:type_name -> hyapp.user.v1.RequestMeta
181, // 71: hyapp.user.v1.BatchGetUsersResponse.users:type_name -> hyapp.user.v1.BatchGetUsersResponse.UsersEntry 183, // 71: hyapp.user.v1.BatchGetUsersResponse.users:type_name -> hyapp.user.v1.BatchGetUsersResponse.UsersEntry
0, // 72: hyapp.user.v1.ActiveUserBanSummary.user_status:type_name -> hyapp.user.v1.UserStatus 0, // 72: hyapp.user.v1.ActiveUserBanSummary.user_status:type_name -> hyapp.user.v1.UserStatus
5, // 73: hyapp.user.v1.UserAdminProfile.user:type_name -> hyapp.user.v1.User 5, // 73: hyapp.user.v1.UserAdminProfile.user:type_name -> hyapp.user.v1.User
83, // 74: hyapp.user.v1.UserAdminProfile.ban:type_name -> hyapp.user.v1.ActiveUserBanSummary 83, // 74: hyapp.user.v1.UserAdminProfile.ban:type_name -> hyapp.user.v1.ActiveUserBanSummary
1, // 75: hyapp.user.v1.BatchGetUserAdminProfilesRequest.meta:type_name -> hyapp.user.v1.RequestMeta 1, // 75: hyapp.user.v1.BatchGetUserAdminProfilesRequest.meta:type_name -> hyapp.user.v1.RequestMeta
182, // 76: hyapp.user.v1.BatchGetUserAdminProfilesResponse.profiles:type_name -> hyapp.user.v1.BatchGetUserAdminProfilesResponse.ProfilesEntry 184, // 76: hyapp.user.v1.BatchGetUserAdminProfilesResponse.profiles:type_name -> hyapp.user.v1.BatchGetUserAdminProfilesResponse.ProfilesEntry
1, // 77: hyapp.user.v1.AdminIssueUserAccessTokenRequest.meta:type_name -> hyapp.user.v1.RequestMeta 1, // 77: hyapp.user.v1.AdminIssueUserAccessTokenRequest.meta:type_name -> hyapp.user.v1.RequestMeta
1, // 78: hyapp.user.v1.BatchGetRoomBasicUsersRequest.meta:type_name -> hyapp.user.v1.RequestMeta 1, // 78: hyapp.user.v1.BatchGetRoomBasicUsersRequest.meta:type_name -> hyapp.user.v1.RequestMeta
183, // 79: hyapp.user.v1.BatchGetRoomBasicUsersResponse.users:type_name -> hyapp.user.v1.BatchGetRoomBasicUsersResponse.UsersEntry 185, // 79: hyapp.user.v1.BatchGetRoomBasicUsersResponse.users:type_name -> hyapp.user.v1.BatchGetRoomBasicUsersResponse.UsersEntry
1, // 80: hyapp.user.v1.ListUserIDsRequest.meta:type_name -> hyapp.user.v1.RequestMeta 1, // 80: hyapp.user.v1.ListUserIDsRequest.meta:type_name -> hyapp.user.v1.RequestMeta
1, // 81: hyapp.user.v1.UpdateUserProfileRequest.meta:type_name -> hyapp.user.v1.RequestMeta 1, // 81: hyapp.user.v1.UpdateUserProfileRequest.meta:type_name -> hyapp.user.v1.RequestMeta
5, // 82: hyapp.user.v1.UpdateUserProfileResponse.user:type_name -> hyapp.user.v1.User 5, // 82: hyapp.user.v1.UpdateUserProfileResponse.user:type_name -> hyapp.user.v1.User
@ -14854,174 +14950,178 @@ var file_proto_user_v1_user_proto_depIdxs = []int32{
49, // 164: hyapp.user.v1.CPFormationGiftFeedItem.target:type_name -> hyapp.user.v1.CPUserProfile 49, // 164: hyapp.user.v1.CPFormationGiftFeedItem.target:type_name -> hyapp.user.v1.CPUserProfile
1, // 165: hyapp.user.v1.ListCPFormationGiftFeedRequest.meta:type_name -> hyapp.user.v1.RequestMeta 1, // 165: hyapp.user.v1.ListCPFormationGiftFeedRequest.meta:type_name -> hyapp.user.v1.RequestMeta
178, // 166: hyapp.user.v1.ListCPFormationGiftFeedResponse.items:type_name -> hyapp.user.v1.CPFormationGiftFeedItem 178, // 166: hyapp.user.v1.ListCPFormationGiftFeedResponse.items:type_name -> hyapp.user.v1.CPFormationGiftFeedItem
5, // 167: hyapp.user.v1.BatchGetUsersResponse.UsersEntry.value:type_name -> hyapp.user.v1.User 1, // 167: hyapp.user.v1.ListAppsRequest.meta:type_name -> hyapp.user.v1.RequestMeta
84, // 168: hyapp.user.v1.BatchGetUserAdminProfilesResponse.ProfilesEntry.value:type_name -> hyapp.user.v1.UserAdminProfile 2, // 168: hyapp.user.v1.ListAppsResponse.apps:type_name -> hyapp.user.v1.App
89, // 169: hyapp.user.v1.BatchGetRoomBasicUsersResponse.UsersEntry.value:type_name -> hyapp.user.v1.RoomBasicUser 5, // 169: hyapp.user.v1.BatchGetUsersResponse.UsersEntry.value:type_name -> hyapp.user.v1.User
17, // 170: hyapp.user.v1.UserService.GetUser:input_type -> hyapp.user.v1.GetUserRequest 84, // 170: hyapp.user.v1.BatchGetUserAdminProfilesResponse.ProfilesEntry.value:type_name -> hyapp.user.v1.UserAdminProfile
9, // 171: hyapp.user.v1.UserService.GetInviteAttribution:input_type -> hyapp.user.v1.GetInviteAttributionRequest 89, // 171: hyapp.user.v1.BatchGetRoomBasicUsersResponse.UsersEntry.value:type_name -> hyapp.user.v1.RoomBasicUser
19, // 172: hyapp.user.v1.UserService.BusinessUserLookup:input_type -> hyapp.user.v1.BusinessUserLookupRequest 17, // 172: hyapp.user.v1.UserService.GetUser:input_type -> hyapp.user.v1.GetUserRequest
23, // 173: hyapp.user.v1.UserService.GetMyProfileStats:input_type -> hyapp.user.v1.GetMyProfileStatsRequest 9, // 173: hyapp.user.v1.UserService.GetInviteAttribution:input_type -> hyapp.user.v1.GetInviteAttributionRequest
81, // 174: hyapp.user.v1.UserService.BatchGetUsers:input_type -> hyapp.user.v1.BatchGetUsersRequest 19, // 174: hyapp.user.v1.UserService.BusinessUserLookup:input_type -> hyapp.user.v1.BusinessUserLookupRequest
85, // 175: hyapp.user.v1.UserService.BatchGetUserAdminProfiles:input_type -> hyapp.user.v1.BatchGetUserAdminProfilesRequest 23, // 175: hyapp.user.v1.UserService.GetMyProfileStats:input_type -> hyapp.user.v1.GetMyProfileStatsRequest
87, // 176: hyapp.user.v1.UserService.AdminIssueUserAccessToken:input_type -> hyapp.user.v1.AdminIssueUserAccessTokenRequest 81, // 176: hyapp.user.v1.UserService.BatchGetUsers:input_type -> hyapp.user.v1.BatchGetUsersRequest
90, // 177: hyapp.user.v1.UserService.BatchGetRoomBasicUsers:input_type -> hyapp.user.v1.BatchGetRoomBasicUsersRequest 85, // 177: hyapp.user.v1.UserService.BatchGetUserAdminProfiles:input_type -> hyapp.user.v1.BatchGetUserAdminProfilesRequest
92, // 178: hyapp.user.v1.UserService.ListUserIDs:input_type -> hyapp.user.v1.ListUserIDsRequest 87, // 178: hyapp.user.v1.UserService.AdminIssueUserAccessToken:input_type -> hyapp.user.v1.AdminIssueUserAccessTokenRequest
12, // 179: hyapp.user.v1.UserService.GetUserMicLifetimeStats:input_type -> hyapp.user.v1.GetUserMicLifetimeStatsRequest 90, // 179: hyapp.user.v1.UserService.BatchGetRoomBasicUsers:input_type -> hyapp.user.v1.BatchGetRoomBasicUsersRequest
94, // 180: hyapp.user.v1.UserService.UpdateUserProfile:input_type -> hyapp.user.v1.UpdateUserProfileRequest 92, // 180: hyapp.user.v1.UserService.ListUserIDs:input_type -> hyapp.user.v1.ListUserIDsRequest
96, // 181: hyapp.user.v1.UserService.UpdateUserProfileBackground:input_type -> hyapp.user.v1.UpdateUserProfileBackgroundRequest 12, // 181: hyapp.user.v1.UserService.GetUserMicLifetimeStats:input_type -> hyapp.user.v1.GetUserMicLifetimeStatsRequest
98, // 182: hyapp.user.v1.UserService.UpdateUserContactInfo:input_type -> hyapp.user.v1.UpdateUserContactInfoRequest 94, // 182: hyapp.user.v1.UserService.UpdateUserProfile:input_type -> hyapp.user.v1.UpdateUserProfileRequest
100, // 183: hyapp.user.v1.UserService.UpdateUserWithdrawAddress:input_type -> hyapp.user.v1.UpdateUserWithdrawAddressRequest 96, // 183: hyapp.user.v1.UserService.UpdateUserProfileBackground:input_type -> hyapp.user.v1.UpdateUserProfileBackgroundRequest
102, // 184: hyapp.user.v1.UserService.ChangeUserCountry:input_type -> hyapp.user.v1.ChangeUserCountryRequest 98, // 184: hyapp.user.v1.UserService.UpdateUserContactInfo:input_type -> hyapp.user.v1.UpdateUserContactInfoRequest
103, // 185: hyapp.user.v1.UserService.AdminChangeUserCountry:input_type -> hyapp.user.v1.AdminChangeUserCountryRequest 100, // 185: hyapp.user.v1.UserService.UpdateUserWithdrawAddress:input_type -> hyapp.user.v1.UpdateUserWithdrawAddressRequest
105, // 186: hyapp.user.v1.UserService.SetUserStatus:input_type -> hyapp.user.v1.SetUserStatusRequest 102, // 186: hyapp.user.v1.UserService.ChangeUserCountry:input_type -> hyapp.user.v1.ChangeUserCountryRequest
108, // 187: hyapp.user.v1.UserService.AdminBanUser:input_type -> hyapp.user.v1.AdminBanUserRequest 103, // 187: hyapp.user.v1.UserService.AdminChangeUserCountry:input_type -> hyapp.user.v1.AdminChangeUserCountryRequest
110, // 188: hyapp.user.v1.UserService.AdminUnbanUser:input_type -> hyapp.user.v1.AdminUnbanUserRequest 105, // 188: hyapp.user.v1.UserService.SetUserStatus:input_type -> hyapp.user.v1.SetUserStatusRequest
113, // 189: hyapp.user.v1.UserService.CreateManagerUserBlock:input_type -> hyapp.user.v1.CreateManagerUserBlockRequest 108, // 189: hyapp.user.v1.UserService.AdminBanUser:input_type -> hyapp.user.v1.AdminBanUserRequest
115, // 190: hyapp.user.v1.UserService.ListManagerUserBlocks:input_type -> hyapp.user.v1.ListManagerUserBlocksRequest 110, // 190: hyapp.user.v1.UserService.AdminUnbanUser:input_type -> hyapp.user.v1.AdminUnbanUserRequest
117, // 191: hyapp.user.v1.UserService.UnblockManagerUser:input_type -> hyapp.user.v1.UnblockManagerUserRequest 113, // 191: hyapp.user.v1.UserService.CreateManagerUserBlock:input_type -> hyapp.user.v1.CreateManagerUserBlockRequest
119, // 192: hyapp.user.v1.UserService.CompleteOnboarding:input_type -> hyapp.user.v1.CompleteOnboardingRequest 115, // 192: hyapp.user.v1.UserService.ListManagerUserBlocks:input_type -> hyapp.user.v1.ListManagerUserBlocksRequest
121, // 193: hyapp.user.v1.UserService.SearchInviteReferrer:input_type -> hyapp.user.v1.SearchInviteReferrerRequest 117, // 193: hyapp.user.v1.UserService.UnblockManagerUser:input_type -> hyapp.user.v1.UnblockManagerUserRequest
123, // 194: hyapp.user.v1.UserService.BindInviteReferrer:input_type -> hyapp.user.v1.BindInviteReferrerRequest 119, // 194: hyapp.user.v1.UserService.CompleteOnboarding:input_type -> hyapp.user.v1.CompleteOnboardingRequest
25, // 195: hyapp.user.v1.UserSocialService.RecordProfileVisit:input_type -> hyapp.user.v1.RecordProfileVisitRequest 121, // 195: hyapp.user.v1.UserService.SearchInviteReferrer:input_type -> hyapp.user.v1.SearchInviteReferrerRequest
28, // 196: hyapp.user.v1.UserSocialService.ListProfileVisitors:input_type -> hyapp.user.v1.ListProfileVisitorsRequest 123, // 196: hyapp.user.v1.UserService.BindInviteReferrer:input_type -> hyapp.user.v1.BindInviteReferrerRequest
30, // 197: hyapp.user.v1.UserSocialService.FollowUser:input_type -> hyapp.user.v1.FollowUserRequest 25, // 197: hyapp.user.v1.UserSocialService.RecordProfileVisit:input_type -> hyapp.user.v1.RecordProfileVisitRequest
32, // 198: hyapp.user.v1.UserSocialService.UnfollowUser:input_type -> hyapp.user.v1.UnfollowUserRequest 28, // 198: hyapp.user.v1.UserSocialService.ListProfileVisitors:input_type -> hyapp.user.v1.ListProfileVisitorsRequest
35, // 199: hyapp.user.v1.UserSocialService.ListFollowing:input_type -> hyapp.user.v1.ListFollowingRequest 30, // 199: hyapp.user.v1.UserSocialService.FollowUser:input_type -> hyapp.user.v1.FollowUserRequest
37, // 200: hyapp.user.v1.UserSocialService.ApplyFriend:input_type -> hyapp.user.v1.ApplyFriendRequest 32, // 200: hyapp.user.v1.UserSocialService.UnfollowUser:input_type -> hyapp.user.v1.UnfollowUserRequest
39, // 201: hyapp.user.v1.UserSocialService.AcceptFriendApplication:input_type -> hyapp.user.v1.AcceptFriendApplicationRequest 35, // 201: hyapp.user.v1.UserSocialService.ListFollowing:input_type -> hyapp.user.v1.ListFollowingRequest
41, // 202: hyapp.user.v1.UserSocialService.DeleteFriend:input_type -> hyapp.user.v1.DeleteFriendRequest 37, // 202: hyapp.user.v1.UserSocialService.ApplyFriend:input_type -> hyapp.user.v1.ApplyFriendRequest
44, // 203: hyapp.user.v1.UserSocialService.ListFriends:input_type -> hyapp.user.v1.ListFriendsRequest 39, // 203: hyapp.user.v1.UserSocialService.AcceptFriendApplication:input_type -> hyapp.user.v1.AcceptFriendApplicationRequest
47, // 204: hyapp.user.v1.UserSocialService.ListFriendApplications:input_type -> hyapp.user.v1.ListFriendApplicationsRequest 41, // 204: hyapp.user.v1.UserSocialService.DeleteFriend:input_type -> hyapp.user.v1.DeleteFriendRequest
79, // 205: hyapp.user.v1.UserSocialService.SubmitReport:input_type -> hyapp.user.v1.SubmitReportRequest 44, // 205: hyapp.user.v1.UserSocialService.ListFriends:input_type -> hyapp.user.v1.ListFriendsRequest
59, // 206: hyapp.user.v1.UserCPService.ListCPApplications:input_type -> hyapp.user.v1.ListCPApplicationsRequest 47, // 206: hyapp.user.v1.UserSocialService.ListFriendApplications:input_type -> hyapp.user.v1.ListFriendApplicationsRequest
61, // 207: hyapp.user.v1.UserCPService.AcceptCPApplication:input_type -> hyapp.user.v1.AcceptCPApplicationRequest 79, // 207: hyapp.user.v1.UserSocialService.SubmitReport:input_type -> hyapp.user.v1.SubmitReportRequest
63, // 208: hyapp.user.v1.UserCPService.RejectCPApplication:input_type -> hyapp.user.v1.RejectCPApplicationRequest 59, // 208: hyapp.user.v1.UserCPService.ListCPApplications:input_type -> hyapp.user.v1.ListCPApplicationsRequest
65, // 209: hyapp.user.v1.UserCPService.ListCPRelationships:input_type -> hyapp.user.v1.ListCPRelationshipsRequest 61, // 209: hyapp.user.v1.UserCPService.AcceptCPApplication:input_type -> hyapp.user.v1.AcceptCPApplicationRequest
67, // 210: hyapp.user.v1.UserCPService.ListCPIntimacyLeaderboard:input_type -> hyapp.user.v1.ListCPIntimacyLeaderboardRequest 63, // 210: hyapp.user.v1.UserCPService.RejectCPApplication:input_type -> hyapp.user.v1.RejectCPApplicationRequest
69, // 211: hyapp.user.v1.UserCPService.PrepareBreakCPRelationship:input_type -> hyapp.user.v1.PrepareBreakCPRelationshipRequest 65, // 211: hyapp.user.v1.UserCPService.ListCPRelationships:input_type -> hyapp.user.v1.ListCPRelationshipsRequest
71, // 212: hyapp.user.v1.UserCPService.ConfirmBreakCPRelationship:input_type -> hyapp.user.v1.ConfirmBreakCPRelationshipRequest 67, // 212: hyapp.user.v1.UserCPService.ListCPIntimacyLeaderboard:input_type -> hyapp.user.v1.ListCPIntimacyLeaderboardRequest
73, // 213: hyapp.user.v1.UserCPService.CancelBreakCPRelationship:input_type -> hyapp.user.v1.CancelBreakCPRelationshipRequest 69, // 213: hyapp.user.v1.UserCPService.PrepareBreakCPRelationship:input_type -> hyapp.user.v1.PrepareBreakCPRelationshipRequest
179, // 214: hyapp.user.v1.UserCPService.ListCPFormationGiftFeed:input_type -> hyapp.user.v1.ListCPFormationGiftFeedRequest 71, // 214: hyapp.user.v1.UserCPService.ConfirmBreakCPRelationship:input_type -> hyapp.user.v1.ConfirmBreakCPRelationshipRequest
76, // 215: hyapp.user.v1.UserCPInternalService.ConsumeRoomGiftCPEvent:input_type -> hyapp.user.v1.ConsumeRoomGiftCPEventRequest 73, // 215: hyapp.user.v1.UserCPService.CancelBreakCPRelationship:input_type -> hyapp.user.v1.CancelBreakCPRelationshipRequest
57, // 216: hyapp.user.v1.UserCPInternalService.ListCPWeeklyRankEntries:input_type -> hyapp.user.v1.ListCPWeeklyRankEntriesRequest 179, // 216: hyapp.user.v1.UserCPService.ListCPFormationGiftFeed:input_type -> hyapp.user.v1.ListCPFormationGiftFeedRequest
14, // 217: hyapp.user.v1.UserCronService.ProcessLoginIPRiskBatch:input_type -> hyapp.user.v1.CronBatchRequest 76, // 217: hyapp.user.v1.UserCPInternalService.ConsumeRoomGiftCPEvent:input_type -> hyapp.user.v1.ConsumeRoomGiftCPEventRequest
14, // 218: hyapp.user.v1.UserCronService.ProcessRegionRebuildBatch:input_type -> hyapp.user.v1.CronBatchRequest 57, // 218: hyapp.user.v1.UserCPInternalService.ListCPWeeklyRankEntries:input_type -> hyapp.user.v1.ListCPWeeklyRankEntriesRequest
14, // 219: hyapp.user.v1.UserCronService.CompensateMicOpenSessions:input_type -> hyapp.user.v1.CronBatchRequest 14, // 219: hyapp.user.v1.UserCronService.ProcessLoginIPRiskBatch:input_type -> hyapp.user.v1.CronBatchRequest
14, // 220: hyapp.user.v1.UserCronService.CompensateRoomOpenSessions:input_type -> hyapp.user.v1.CronBatchRequest 14, // 220: hyapp.user.v1.UserCronService.ProcessRegionRebuildBatch:input_type -> hyapp.user.v1.CronBatchRequest
14, // 221: hyapp.user.v1.UserCronService.ExpireManagerUserBlocks:input_type -> hyapp.user.v1.CronBatchRequest 14, // 221: hyapp.user.v1.UserCronService.CompensateMicOpenSessions:input_type -> hyapp.user.v1.CronBatchRequest
14, // 222: hyapp.user.v1.UserCronService.ExpireAdminUserBans:input_type -> hyapp.user.v1.CronBatchRequest 14, // 222: hyapp.user.v1.UserCronService.CompensateRoomOpenSessions:input_type -> hyapp.user.v1.CronBatchRequest
14, // 223: hyapp.user.v1.UserCronService.RefreshCPIntimacyLeaderboard:input_type -> hyapp.user.v1.CronBatchRequest 14, // 223: hyapp.user.v1.UserCronService.ExpireManagerUserBlocks:input_type -> hyapp.user.v1.CronBatchRequest
125, // 224: hyapp.user.v1.UserDeviceService.BindPushToken:input_type -> hyapp.user.v1.BindPushTokenRequest 14, // 224: hyapp.user.v1.UserCronService.ExpireAdminUserBans:input_type -> hyapp.user.v1.CronBatchRequest
127, // 225: hyapp.user.v1.UserDeviceService.DeletePushToken:input_type -> hyapp.user.v1.DeletePushTokenRequest 14, // 225: hyapp.user.v1.UserCronService.RefreshCPIntimacyLeaderboard:input_type -> hyapp.user.v1.CronBatchRequest
3, // 226: hyapp.user.v1.AppRegistryService.ResolveApp:input_type -> hyapp.user.v1.ResolveAppRequest 125, // 226: hyapp.user.v1.UserDeviceService.BindPushToken:input_type -> hyapp.user.v1.BindPushTokenRequest
131, // 227: hyapp.user.v1.CountryAdminService.ListCountries:input_type -> hyapp.user.v1.ListCountriesRequest 127, // 227: hyapp.user.v1.UserDeviceService.DeletePushToken:input_type -> hyapp.user.v1.DeletePushTokenRequest
133, // 228: hyapp.user.v1.CountryAdminService.UpdateCountry:input_type -> hyapp.user.v1.UpdateCountryRequest 3, // 228: hyapp.user.v1.AppRegistryService.ResolveApp:input_type -> hyapp.user.v1.ResolveAppRequest
135, // 229: hyapp.user.v1.CountryQueryService.ListRegistrationCountries:input_type -> hyapp.user.v1.ListRegistrationCountriesRequest 181, // 229: hyapp.user.v1.AppRegistryService.ListApps:input_type -> hyapp.user.v1.ListAppsRequest
138, // 230: hyapp.user.v1.CountryQueryService.ListLoginRiskBlockedCountries:input_type -> hyapp.user.v1.ListLoginRiskBlockedCountriesRequest 131, // 230: hyapp.user.v1.CountryAdminService.ListCountries:input_type -> hyapp.user.v1.ListCountriesRequest
140, // 231: hyapp.user.v1.RegionAdminService.ListRegions:input_type -> hyapp.user.v1.ListRegionsRequest 133, // 231: hyapp.user.v1.CountryAdminService.UpdateCountry:input_type -> hyapp.user.v1.UpdateCountryRequest
142, // 232: hyapp.user.v1.RegionAdminService.GetRegion:input_type -> hyapp.user.v1.GetRegionRequest 135, // 232: hyapp.user.v1.CountryQueryService.ListRegistrationCountries:input_type -> hyapp.user.v1.ListRegistrationCountriesRequest
143, // 233: hyapp.user.v1.RegionAdminService.UpdateRegion:input_type -> hyapp.user.v1.UpdateRegionRequest 138, // 233: hyapp.user.v1.CountryQueryService.ListLoginRiskBlockedCountries:input_type -> hyapp.user.v1.ListLoginRiskBlockedCountriesRequest
144, // 234: hyapp.user.v1.RegionAdminService.ReplaceRegionCountries:input_type -> hyapp.user.v1.ReplaceRegionCountriesRequest 140, // 234: hyapp.user.v1.RegionAdminService.ListRegions:input_type -> hyapp.user.v1.ListRegionsRequest
147, // 235: hyapp.user.v1.UserIdentityService.GetUserIdentity:input_type -> hyapp.user.v1.GetUserIdentityRequest 142, // 235: hyapp.user.v1.RegionAdminService.GetRegion:input_type -> hyapp.user.v1.GetRegionRequest
149, // 236: hyapp.user.v1.UserIdentityService.ResolveDisplayUserID:input_type -> hyapp.user.v1.ResolveDisplayUserIDRequest 143, // 236: hyapp.user.v1.RegionAdminService.UpdateRegion:input_type -> hyapp.user.v1.UpdateRegionRequest
151, // 237: hyapp.user.v1.UserIdentityService.ChangeDisplayUserID:input_type -> hyapp.user.v1.ChangeDisplayUserIDRequest 144, // 237: hyapp.user.v1.RegionAdminService.ReplaceRegionCountries:input_type -> hyapp.user.v1.ReplaceRegionCountriesRequest
153, // 238: hyapp.user.v1.UserIdentityService.ApplyPrettyDisplayUserID:input_type -> hyapp.user.v1.ApplyPrettyDisplayUserIDRequest 147, // 238: hyapp.user.v1.UserIdentityService.GetUserIdentity:input_type -> hyapp.user.v1.GetUserIdentityRequest
160, // 239: hyapp.user.v1.UserIdentityService.ListAvailablePrettyDisplayIDs:input_type -> hyapp.user.v1.ListAvailablePrettyDisplayIDsRequest 149, // 239: hyapp.user.v1.UserIdentityService.ResolveDisplayUserID:input_type -> hyapp.user.v1.ResolveDisplayUserIDRequest
162, // 240: hyapp.user.v1.UserIdentityService.ApplyPrettyDisplayIDFromPool:input_type -> hyapp.user.v1.ApplyPrettyDisplayIDFromPoolRequest 151, // 240: hyapp.user.v1.UserIdentityService.ChangeDisplayUserID:input_type -> hyapp.user.v1.ChangeDisplayUserIDRequest
155, // 241: hyapp.user.v1.UserIdentityService.ExpirePrettyDisplayUserID:input_type -> hyapp.user.v1.ExpirePrettyDisplayUserIDRequest 153, // 241: hyapp.user.v1.UserIdentityService.ApplyPrettyDisplayUserID:input_type -> hyapp.user.v1.ApplyPrettyDisplayUserIDRequest
164, // 242: hyapp.user.v1.UserPrettyDisplayIDAdminService.ListPrettyDisplayIDPools:input_type -> hyapp.user.v1.ListPrettyDisplayIDPoolsRequest 160, // 242: hyapp.user.v1.UserIdentityService.ListAvailablePrettyDisplayIDs:input_type -> hyapp.user.v1.ListAvailablePrettyDisplayIDsRequest
166, // 243: hyapp.user.v1.UserPrettyDisplayIDAdminService.CreatePrettyDisplayIDPool:input_type -> hyapp.user.v1.CreatePrettyDisplayIDPoolRequest 162, // 243: hyapp.user.v1.UserIdentityService.ApplyPrettyDisplayIDFromPool:input_type -> hyapp.user.v1.ApplyPrettyDisplayIDFromPoolRequest
167, // 244: hyapp.user.v1.UserPrettyDisplayIDAdminService.UpdatePrettyDisplayIDPool:input_type -> hyapp.user.v1.UpdatePrettyDisplayIDPoolRequest 155, // 244: hyapp.user.v1.UserIdentityService.ExpirePrettyDisplayUserID:input_type -> hyapp.user.v1.ExpirePrettyDisplayUserIDRequest
169, // 245: hyapp.user.v1.UserPrettyDisplayIDAdminService.GeneratePrettyDisplayIDs:input_type -> hyapp.user.v1.GeneratePrettyDisplayIDsRequest 164, // 245: hyapp.user.v1.UserPrettyDisplayIDAdminService.ListPrettyDisplayIDPools:input_type -> hyapp.user.v1.ListPrettyDisplayIDPoolsRequest
171, // 246: hyapp.user.v1.UserPrettyDisplayIDAdminService.ListPrettyDisplayIDs:input_type -> hyapp.user.v1.ListPrettyDisplayIDsRequest 166, // 246: hyapp.user.v1.UserPrettyDisplayIDAdminService.CreatePrettyDisplayIDPool:input_type -> hyapp.user.v1.CreatePrettyDisplayIDPoolRequest
173, // 247: hyapp.user.v1.UserPrettyDisplayIDAdminService.SetPrettyDisplayIDStatus:input_type -> hyapp.user.v1.SetPrettyDisplayIDStatusRequest 167, // 247: hyapp.user.v1.UserPrettyDisplayIDAdminService.UpdatePrettyDisplayIDPool:input_type -> hyapp.user.v1.UpdatePrettyDisplayIDPoolRequest
174, // 248: hyapp.user.v1.UserPrettyDisplayIDAdminService.RecyclePrettyDisplayID:input_type -> hyapp.user.v1.RecyclePrettyDisplayIDRequest 169, // 248: hyapp.user.v1.UserPrettyDisplayIDAdminService.GeneratePrettyDisplayIDs:input_type -> hyapp.user.v1.GeneratePrettyDisplayIDsRequest
176, // 249: hyapp.user.v1.UserPrettyDisplayIDAdminService.AdminGrantPrettyDisplayID:input_type -> hyapp.user.v1.AdminGrantPrettyDisplayIDRequest 171, // 249: hyapp.user.v1.UserPrettyDisplayIDAdminService.ListPrettyDisplayIDs:input_type -> hyapp.user.v1.ListPrettyDisplayIDsRequest
18, // 250: hyapp.user.v1.UserService.GetUser:output_type -> hyapp.user.v1.GetUserResponse 173, // 250: hyapp.user.v1.UserPrettyDisplayIDAdminService.SetPrettyDisplayIDStatus:input_type -> hyapp.user.v1.SetPrettyDisplayIDStatusRequest
10, // 251: hyapp.user.v1.UserService.GetInviteAttribution:output_type -> hyapp.user.v1.GetInviteAttributionResponse 174, // 251: hyapp.user.v1.UserPrettyDisplayIDAdminService.RecyclePrettyDisplayID:input_type -> hyapp.user.v1.RecyclePrettyDisplayIDRequest
21, // 252: hyapp.user.v1.UserService.BusinessUserLookup:output_type -> hyapp.user.v1.BusinessUserLookupResponse 176, // 252: hyapp.user.v1.UserPrettyDisplayIDAdminService.AdminGrantPrettyDisplayID:input_type -> hyapp.user.v1.AdminGrantPrettyDisplayIDRequest
24, // 253: hyapp.user.v1.UserService.GetMyProfileStats:output_type -> hyapp.user.v1.GetMyProfileStatsResponse 18, // 253: hyapp.user.v1.UserService.GetUser:output_type -> hyapp.user.v1.GetUserResponse
82, // 254: hyapp.user.v1.UserService.BatchGetUsers:output_type -> hyapp.user.v1.BatchGetUsersResponse 10, // 254: hyapp.user.v1.UserService.GetInviteAttribution:output_type -> hyapp.user.v1.GetInviteAttributionResponse
86, // 255: hyapp.user.v1.UserService.BatchGetUserAdminProfiles:output_type -> hyapp.user.v1.BatchGetUserAdminProfilesResponse 21, // 255: hyapp.user.v1.UserService.BusinessUserLookup:output_type -> hyapp.user.v1.BusinessUserLookupResponse
88, // 256: hyapp.user.v1.UserService.AdminIssueUserAccessToken:output_type -> hyapp.user.v1.AdminIssueUserAccessTokenResponse 24, // 256: hyapp.user.v1.UserService.GetMyProfileStats:output_type -> hyapp.user.v1.GetMyProfileStatsResponse
91, // 257: hyapp.user.v1.UserService.BatchGetRoomBasicUsers:output_type -> hyapp.user.v1.BatchGetRoomBasicUsersResponse 82, // 257: hyapp.user.v1.UserService.BatchGetUsers:output_type -> hyapp.user.v1.BatchGetUsersResponse
93, // 258: hyapp.user.v1.UserService.ListUserIDs:output_type -> hyapp.user.v1.ListUserIDsResponse 86, // 258: hyapp.user.v1.UserService.BatchGetUserAdminProfiles:output_type -> hyapp.user.v1.BatchGetUserAdminProfilesResponse
13, // 259: hyapp.user.v1.UserService.GetUserMicLifetimeStats:output_type -> hyapp.user.v1.GetUserMicLifetimeStatsResponse 88, // 259: hyapp.user.v1.UserService.AdminIssueUserAccessToken:output_type -> hyapp.user.v1.AdminIssueUserAccessTokenResponse
95, // 260: hyapp.user.v1.UserService.UpdateUserProfile:output_type -> hyapp.user.v1.UpdateUserProfileResponse 91, // 260: hyapp.user.v1.UserService.BatchGetRoomBasicUsers:output_type -> hyapp.user.v1.BatchGetRoomBasicUsersResponse
97, // 261: hyapp.user.v1.UserService.UpdateUserProfileBackground:output_type -> hyapp.user.v1.UpdateUserProfileBackgroundResponse 93, // 261: hyapp.user.v1.UserService.ListUserIDs:output_type -> hyapp.user.v1.ListUserIDsResponse
99, // 262: hyapp.user.v1.UserService.UpdateUserContactInfo:output_type -> hyapp.user.v1.UpdateUserContactInfoResponse 13, // 262: hyapp.user.v1.UserService.GetUserMicLifetimeStats:output_type -> hyapp.user.v1.GetUserMicLifetimeStatsResponse
101, // 263: hyapp.user.v1.UserService.UpdateUserWithdrawAddress:output_type -> hyapp.user.v1.UpdateUserWithdrawAddressResponse 95, // 263: hyapp.user.v1.UserService.UpdateUserProfile:output_type -> hyapp.user.v1.UpdateUserProfileResponse
104, // 264: hyapp.user.v1.UserService.ChangeUserCountry:output_type -> hyapp.user.v1.ChangeUserCountryResponse 97, // 264: hyapp.user.v1.UserService.UpdateUserProfileBackground:output_type -> hyapp.user.v1.UpdateUserProfileBackgroundResponse
104, // 265: hyapp.user.v1.UserService.AdminChangeUserCountry:output_type -> hyapp.user.v1.ChangeUserCountryResponse 99, // 265: hyapp.user.v1.UserService.UpdateUserContactInfo:output_type -> hyapp.user.v1.UpdateUserContactInfoResponse
106, // 266: hyapp.user.v1.UserService.SetUserStatus:output_type -> hyapp.user.v1.SetUserStatusResponse 101, // 266: hyapp.user.v1.UserService.UpdateUserWithdrawAddress:output_type -> hyapp.user.v1.UpdateUserWithdrawAddressResponse
109, // 267: hyapp.user.v1.UserService.AdminBanUser:output_type -> hyapp.user.v1.AdminBanUserResponse 104, // 267: hyapp.user.v1.UserService.ChangeUserCountry:output_type -> hyapp.user.v1.ChangeUserCountryResponse
111, // 268: hyapp.user.v1.UserService.AdminUnbanUser:output_type -> hyapp.user.v1.AdminUnbanUserResponse 104, // 268: hyapp.user.v1.UserService.AdminChangeUserCountry:output_type -> hyapp.user.v1.ChangeUserCountryResponse
114, // 269: hyapp.user.v1.UserService.CreateManagerUserBlock:output_type -> hyapp.user.v1.CreateManagerUserBlockResponse 106, // 269: hyapp.user.v1.UserService.SetUserStatus:output_type -> hyapp.user.v1.SetUserStatusResponse
116, // 270: hyapp.user.v1.UserService.ListManagerUserBlocks:output_type -> hyapp.user.v1.ListManagerUserBlocksResponse 109, // 270: hyapp.user.v1.UserService.AdminBanUser:output_type -> hyapp.user.v1.AdminBanUserResponse
118, // 271: hyapp.user.v1.UserService.UnblockManagerUser:output_type -> hyapp.user.v1.UnblockManagerUserResponse 111, // 271: hyapp.user.v1.UserService.AdminUnbanUser:output_type -> hyapp.user.v1.AdminUnbanUserResponse
120, // 272: hyapp.user.v1.UserService.CompleteOnboarding:output_type -> hyapp.user.v1.CompleteOnboardingResponse 114, // 272: hyapp.user.v1.UserService.CreateManagerUserBlock:output_type -> hyapp.user.v1.CreateManagerUserBlockResponse
122, // 273: hyapp.user.v1.UserService.SearchInviteReferrer:output_type -> hyapp.user.v1.SearchInviteReferrerResponse 116, // 273: hyapp.user.v1.UserService.ListManagerUserBlocks:output_type -> hyapp.user.v1.ListManagerUserBlocksResponse
124, // 274: hyapp.user.v1.UserService.BindInviteReferrer:output_type -> hyapp.user.v1.BindInviteReferrerResponse 118, // 274: hyapp.user.v1.UserService.UnblockManagerUser:output_type -> hyapp.user.v1.UnblockManagerUserResponse
26, // 275: hyapp.user.v1.UserSocialService.RecordProfileVisit:output_type -> hyapp.user.v1.RecordProfileVisitResponse 120, // 275: hyapp.user.v1.UserService.CompleteOnboarding:output_type -> hyapp.user.v1.CompleteOnboardingResponse
29, // 276: hyapp.user.v1.UserSocialService.ListProfileVisitors:output_type -> hyapp.user.v1.ListProfileVisitorsResponse 122, // 276: hyapp.user.v1.UserService.SearchInviteReferrer:output_type -> hyapp.user.v1.SearchInviteReferrerResponse
31, // 277: hyapp.user.v1.UserSocialService.FollowUser:output_type -> hyapp.user.v1.FollowUserResponse 124, // 277: hyapp.user.v1.UserService.BindInviteReferrer:output_type -> hyapp.user.v1.BindInviteReferrerResponse
33, // 278: hyapp.user.v1.UserSocialService.UnfollowUser:output_type -> hyapp.user.v1.UnfollowUserResponse 26, // 278: hyapp.user.v1.UserSocialService.RecordProfileVisit:output_type -> hyapp.user.v1.RecordProfileVisitResponse
36, // 279: hyapp.user.v1.UserSocialService.ListFollowing:output_type -> hyapp.user.v1.ListFollowingResponse 29, // 279: hyapp.user.v1.UserSocialService.ListProfileVisitors:output_type -> hyapp.user.v1.ListProfileVisitorsResponse
38, // 280: hyapp.user.v1.UserSocialService.ApplyFriend:output_type -> hyapp.user.v1.ApplyFriendResponse 31, // 280: hyapp.user.v1.UserSocialService.FollowUser:output_type -> hyapp.user.v1.FollowUserResponse
40, // 281: hyapp.user.v1.UserSocialService.AcceptFriendApplication:output_type -> hyapp.user.v1.AcceptFriendApplicationResponse 33, // 281: hyapp.user.v1.UserSocialService.UnfollowUser:output_type -> hyapp.user.v1.UnfollowUserResponse
42, // 282: hyapp.user.v1.UserSocialService.DeleteFriend:output_type -> hyapp.user.v1.DeleteFriendResponse 36, // 282: hyapp.user.v1.UserSocialService.ListFollowing:output_type -> hyapp.user.v1.ListFollowingResponse
45, // 283: hyapp.user.v1.UserSocialService.ListFriends:output_type -> hyapp.user.v1.ListFriendsResponse 38, // 283: hyapp.user.v1.UserSocialService.ApplyFriend:output_type -> hyapp.user.v1.ApplyFriendResponse
48, // 284: hyapp.user.v1.UserSocialService.ListFriendApplications:output_type -> hyapp.user.v1.ListFriendApplicationsResponse 40, // 284: hyapp.user.v1.UserSocialService.AcceptFriendApplication:output_type -> hyapp.user.v1.AcceptFriendApplicationResponse
80, // 285: hyapp.user.v1.UserSocialService.SubmitReport:output_type -> hyapp.user.v1.SubmitReportResponse 42, // 285: hyapp.user.v1.UserSocialService.DeleteFriend:output_type -> hyapp.user.v1.DeleteFriendResponse
60, // 286: hyapp.user.v1.UserCPService.ListCPApplications:output_type -> hyapp.user.v1.ListCPApplicationsResponse 45, // 286: hyapp.user.v1.UserSocialService.ListFriends:output_type -> hyapp.user.v1.ListFriendsResponse
62, // 287: hyapp.user.v1.UserCPService.AcceptCPApplication:output_type -> hyapp.user.v1.AcceptCPApplicationResponse 48, // 287: hyapp.user.v1.UserSocialService.ListFriendApplications:output_type -> hyapp.user.v1.ListFriendApplicationsResponse
64, // 288: hyapp.user.v1.UserCPService.RejectCPApplication:output_type -> hyapp.user.v1.RejectCPApplicationResponse 80, // 288: hyapp.user.v1.UserSocialService.SubmitReport:output_type -> hyapp.user.v1.SubmitReportResponse
66, // 289: hyapp.user.v1.UserCPService.ListCPRelationships:output_type -> hyapp.user.v1.ListCPRelationshipsResponse 60, // 289: hyapp.user.v1.UserCPService.ListCPApplications:output_type -> hyapp.user.v1.ListCPApplicationsResponse
68, // 290: hyapp.user.v1.UserCPService.ListCPIntimacyLeaderboard:output_type -> hyapp.user.v1.ListCPIntimacyLeaderboardResponse 62, // 290: hyapp.user.v1.UserCPService.AcceptCPApplication:output_type -> hyapp.user.v1.AcceptCPApplicationResponse
70, // 291: hyapp.user.v1.UserCPService.PrepareBreakCPRelationship:output_type -> hyapp.user.v1.PrepareBreakCPRelationshipResponse 64, // 291: hyapp.user.v1.UserCPService.RejectCPApplication:output_type -> hyapp.user.v1.RejectCPApplicationResponse
72, // 292: hyapp.user.v1.UserCPService.ConfirmBreakCPRelationship:output_type -> hyapp.user.v1.ConfirmBreakCPRelationshipResponse 66, // 292: hyapp.user.v1.UserCPService.ListCPRelationships:output_type -> hyapp.user.v1.ListCPRelationshipsResponse
74, // 293: hyapp.user.v1.UserCPService.CancelBreakCPRelationship:output_type -> hyapp.user.v1.CancelBreakCPRelationshipResponse 68, // 293: hyapp.user.v1.UserCPService.ListCPIntimacyLeaderboard:output_type -> hyapp.user.v1.ListCPIntimacyLeaderboardResponse
180, // 294: hyapp.user.v1.UserCPService.ListCPFormationGiftFeed:output_type -> hyapp.user.v1.ListCPFormationGiftFeedResponse 70, // 294: hyapp.user.v1.UserCPService.PrepareBreakCPRelationship:output_type -> hyapp.user.v1.PrepareBreakCPRelationshipResponse
77, // 295: hyapp.user.v1.UserCPInternalService.ConsumeRoomGiftCPEvent:output_type -> hyapp.user.v1.ConsumeRoomGiftCPEventResponse 72, // 295: hyapp.user.v1.UserCPService.ConfirmBreakCPRelationship:output_type -> hyapp.user.v1.ConfirmBreakCPRelationshipResponse
58, // 296: hyapp.user.v1.UserCPInternalService.ListCPWeeklyRankEntries:output_type -> hyapp.user.v1.ListCPWeeklyRankEntriesResponse 74, // 296: hyapp.user.v1.UserCPService.CancelBreakCPRelationship:output_type -> hyapp.user.v1.CancelBreakCPRelationshipResponse
15, // 297: hyapp.user.v1.UserCronService.ProcessLoginIPRiskBatch:output_type -> hyapp.user.v1.CronBatchResponse 180, // 297: hyapp.user.v1.UserCPService.ListCPFormationGiftFeed:output_type -> hyapp.user.v1.ListCPFormationGiftFeedResponse
15, // 298: hyapp.user.v1.UserCronService.ProcessRegionRebuildBatch:output_type -> hyapp.user.v1.CronBatchResponse 77, // 298: hyapp.user.v1.UserCPInternalService.ConsumeRoomGiftCPEvent:output_type -> hyapp.user.v1.ConsumeRoomGiftCPEventResponse
15, // 299: hyapp.user.v1.UserCronService.CompensateMicOpenSessions:output_type -> hyapp.user.v1.CronBatchResponse 58, // 299: hyapp.user.v1.UserCPInternalService.ListCPWeeklyRankEntries:output_type -> hyapp.user.v1.ListCPWeeklyRankEntriesResponse
15, // 300: hyapp.user.v1.UserCronService.CompensateRoomOpenSessions:output_type -> hyapp.user.v1.CronBatchResponse 15, // 300: hyapp.user.v1.UserCronService.ProcessLoginIPRiskBatch:output_type -> hyapp.user.v1.CronBatchResponse
15, // 301: hyapp.user.v1.UserCronService.ExpireManagerUserBlocks:output_type -> hyapp.user.v1.CronBatchResponse 15, // 301: hyapp.user.v1.UserCronService.ProcessRegionRebuildBatch:output_type -> hyapp.user.v1.CronBatchResponse
15, // 302: hyapp.user.v1.UserCronService.ExpireAdminUserBans:output_type -> hyapp.user.v1.CronBatchResponse 15, // 302: hyapp.user.v1.UserCronService.CompensateMicOpenSessions:output_type -> hyapp.user.v1.CronBatchResponse
15, // 303: hyapp.user.v1.UserCronService.RefreshCPIntimacyLeaderboard:output_type -> hyapp.user.v1.CronBatchResponse 15, // 303: hyapp.user.v1.UserCronService.CompensateRoomOpenSessions:output_type -> hyapp.user.v1.CronBatchResponse
126, // 304: hyapp.user.v1.UserDeviceService.BindPushToken:output_type -> hyapp.user.v1.BindPushTokenResponse 15, // 304: hyapp.user.v1.UserCronService.ExpireManagerUserBlocks:output_type -> hyapp.user.v1.CronBatchResponse
128, // 305: hyapp.user.v1.UserDeviceService.DeletePushToken:output_type -> hyapp.user.v1.DeletePushTokenResponse 15, // 305: hyapp.user.v1.UserCronService.ExpireAdminUserBans:output_type -> hyapp.user.v1.CronBatchResponse
4, // 306: hyapp.user.v1.AppRegistryService.ResolveApp:output_type -> hyapp.user.v1.ResolveAppResponse 15, // 306: hyapp.user.v1.UserCronService.RefreshCPIntimacyLeaderboard:output_type -> hyapp.user.v1.CronBatchResponse
132, // 307: hyapp.user.v1.CountryAdminService.ListCountries:output_type -> hyapp.user.v1.ListCountriesResponse 126, // 307: hyapp.user.v1.UserDeviceService.BindPushToken:output_type -> hyapp.user.v1.BindPushTokenResponse
134, // 308: hyapp.user.v1.CountryAdminService.UpdateCountry:output_type -> hyapp.user.v1.CountryResponse 128, // 308: hyapp.user.v1.UserDeviceService.DeletePushToken:output_type -> hyapp.user.v1.DeletePushTokenResponse
136, // 309: hyapp.user.v1.CountryQueryService.ListRegistrationCountries:output_type -> hyapp.user.v1.ListRegistrationCountriesResponse 4, // 309: hyapp.user.v1.AppRegistryService.ResolveApp:output_type -> hyapp.user.v1.ResolveAppResponse
139, // 310: hyapp.user.v1.CountryQueryService.ListLoginRiskBlockedCountries:output_type -> hyapp.user.v1.ListLoginRiskBlockedCountriesResponse 182, // 310: hyapp.user.v1.AppRegistryService.ListApps:output_type -> hyapp.user.v1.ListAppsResponse
141, // 311: hyapp.user.v1.RegionAdminService.ListRegions:output_type -> hyapp.user.v1.ListRegionsResponse 132, // 311: hyapp.user.v1.CountryAdminService.ListCountries:output_type -> hyapp.user.v1.ListCountriesResponse
145, // 312: hyapp.user.v1.RegionAdminService.GetRegion:output_type -> hyapp.user.v1.RegionResponse 134, // 312: hyapp.user.v1.CountryAdminService.UpdateCountry:output_type -> hyapp.user.v1.CountryResponse
145, // 313: hyapp.user.v1.RegionAdminService.UpdateRegion:output_type -> hyapp.user.v1.RegionResponse 136, // 313: hyapp.user.v1.CountryQueryService.ListRegistrationCountries:output_type -> hyapp.user.v1.ListRegistrationCountriesResponse
145, // 314: hyapp.user.v1.RegionAdminService.ReplaceRegionCountries:output_type -> hyapp.user.v1.RegionResponse 139, // 314: hyapp.user.v1.CountryQueryService.ListLoginRiskBlockedCountries:output_type -> hyapp.user.v1.ListLoginRiskBlockedCountriesResponse
148, // 315: hyapp.user.v1.UserIdentityService.GetUserIdentity:output_type -> hyapp.user.v1.GetUserIdentityResponse 141, // 315: hyapp.user.v1.RegionAdminService.ListRegions:output_type -> hyapp.user.v1.ListRegionsResponse
150, // 316: hyapp.user.v1.UserIdentityService.ResolveDisplayUserID:output_type -> hyapp.user.v1.ResolveDisplayUserIDResponse 145, // 316: hyapp.user.v1.RegionAdminService.GetRegion:output_type -> hyapp.user.v1.RegionResponse
152, // 317: hyapp.user.v1.UserIdentityService.ChangeDisplayUserID:output_type -> hyapp.user.v1.ChangeDisplayUserIDResponse 145, // 317: hyapp.user.v1.RegionAdminService.UpdateRegion:output_type -> hyapp.user.v1.RegionResponse
154, // 318: hyapp.user.v1.UserIdentityService.ApplyPrettyDisplayUserID:output_type -> hyapp.user.v1.ApplyPrettyDisplayUserIDResponse 145, // 318: hyapp.user.v1.RegionAdminService.ReplaceRegionCountries:output_type -> hyapp.user.v1.RegionResponse
161, // 319: hyapp.user.v1.UserIdentityService.ListAvailablePrettyDisplayIDs:output_type -> hyapp.user.v1.ListAvailablePrettyDisplayIDsResponse 148, // 319: hyapp.user.v1.UserIdentityService.GetUserIdentity:output_type -> hyapp.user.v1.GetUserIdentityResponse
163, // 320: hyapp.user.v1.UserIdentityService.ApplyPrettyDisplayIDFromPool:output_type -> hyapp.user.v1.ApplyPrettyDisplayIDFromPoolResponse 150, // 320: hyapp.user.v1.UserIdentityService.ResolveDisplayUserID:output_type -> hyapp.user.v1.ResolveDisplayUserIDResponse
156, // 321: hyapp.user.v1.UserIdentityService.ExpirePrettyDisplayUserID:output_type -> hyapp.user.v1.ExpirePrettyDisplayUserIDResponse 152, // 321: hyapp.user.v1.UserIdentityService.ChangeDisplayUserID:output_type -> hyapp.user.v1.ChangeDisplayUserIDResponse
165, // 322: hyapp.user.v1.UserPrettyDisplayIDAdminService.ListPrettyDisplayIDPools:output_type -> hyapp.user.v1.ListPrettyDisplayIDPoolsResponse 154, // 322: hyapp.user.v1.UserIdentityService.ApplyPrettyDisplayUserID:output_type -> hyapp.user.v1.ApplyPrettyDisplayUserIDResponse
168, // 323: hyapp.user.v1.UserPrettyDisplayIDAdminService.CreatePrettyDisplayIDPool:output_type -> hyapp.user.v1.PrettyDisplayIDPoolResponse 161, // 323: hyapp.user.v1.UserIdentityService.ListAvailablePrettyDisplayIDs:output_type -> hyapp.user.v1.ListAvailablePrettyDisplayIDsResponse
168, // 324: hyapp.user.v1.UserPrettyDisplayIDAdminService.UpdatePrettyDisplayIDPool:output_type -> hyapp.user.v1.PrettyDisplayIDPoolResponse 163, // 324: hyapp.user.v1.UserIdentityService.ApplyPrettyDisplayIDFromPool:output_type -> hyapp.user.v1.ApplyPrettyDisplayIDFromPoolResponse
170, // 325: hyapp.user.v1.UserPrettyDisplayIDAdminService.GeneratePrettyDisplayIDs:output_type -> hyapp.user.v1.GeneratePrettyDisplayIDsResponse 156, // 325: hyapp.user.v1.UserIdentityService.ExpirePrettyDisplayUserID:output_type -> hyapp.user.v1.ExpirePrettyDisplayUserIDResponse
172, // 326: hyapp.user.v1.UserPrettyDisplayIDAdminService.ListPrettyDisplayIDs:output_type -> hyapp.user.v1.ListPrettyDisplayIDsResponse 165, // 326: hyapp.user.v1.UserPrettyDisplayIDAdminService.ListPrettyDisplayIDPools:output_type -> hyapp.user.v1.ListPrettyDisplayIDPoolsResponse
175, // 327: hyapp.user.v1.UserPrettyDisplayIDAdminService.SetPrettyDisplayIDStatus:output_type -> hyapp.user.v1.PrettyDisplayIDResponse 168, // 327: hyapp.user.v1.UserPrettyDisplayIDAdminService.CreatePrettyDisplayIDPool:output_type -> hyapp.user.v1.PrettyDisplayIDPoolResponse
175, // 328: hyapp.user.v1.UserPrettyDisplayIDAdminService.RecyclePrettyDisplayID:output_type -> hyapp.user.v1.PrettyDisplayIDResponse 168, // 328: hyapp.user.v1.UserPrettyDisplayIDAdminService.UpdatePrettyDisplayIDPool:output_type -> hyapp.user.v1.PrettyDisplayIDPoolResponse
177, // 329: hyapp.user.v1.UserPrettyDisplayIDAdminService.AdminGrantPrettyDisplayID:output_type -> hyapp.user.v1.AdminGrantPrettyDisplayIDResponse 170, // 329: hyapp.user.v1.UserPrettyDisplayIDAdminService.GeneratePrettyDisplayIDs:output_type -> hyapp.user.v1.GeneratePrettyDisplayIDsResponse
250, // [250:330] is the sub-list for method output_type 172, // 330: hyapp.user.v1.UserPrettyDisplayIDAdminService.ListPrettyDisplayIDs:output_type -> hyapp.user.v1.ListPrettyDisplayIDsResponse
170, // [170:250] is the sub-list for method input_type 175, // 331: hyapp.user.v1.UserPrettyDisplayIDAdminService.SetPrettyDisplayIDStatus:output_type -> hyapp.user.v1.PrettyDisplayIDResponse
170, // [170:170] is the sub-list for extension type_name 175, // 332: hyapp.user.v1.UserPrettyDisplayIDAdminService.RecyclePrettyDisplayID:output_type -> hyapp.user.v1.PrettyDisplayIDResponse
170, // [170:170] is the sub-list for extension extendee 177, // 333: hyapp.user.v1.UserPrettyDisplayIDAdminService.AdminGrantPrettyDisplayID:output_type -> hyapp.user.v1.AdminGrantPrettyDisplayIDResponse
0, // [0:170] is the sub-list for field type_name 253, // [253:334] is the sub-list for method output_type
172, // [172:253] is the sub-list for method input_type
172, // [172:172] is the sub-list for extension type_name
172, // [172:172] is the sub-list for extension extendee
0, // [0:172] is the sub-list for field type_name
} }
func init() { file_proto_user_v1_user_proto_init() } func init() { file_proto_user_v1_user_proto_init() }
@ -15037,7 +15137,7 @@ func file_proto_user_v1_user_proto_init() {
GoPackagePath: reflect.TypeOf(x{}).PkgPath(), GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
RawDescriptor: unsafe.Slice(unsafe.StringData(file_proto_user_v1_user_proto_rawDesc), len(file_proto_user_v1_user_proto_rawDesc)), RawDescriptor: unsafe.Slice(unsafe.StringData(file_proto_user_v1_user_proto_rawDesc), len(file_proto_user_v1_user_proto_rawDesc)),
NumEnums: 1, NumEnums: 1,
NumMessages: 183, NumMessages: 185,
NumExtensions: 0, NumExtensions: 0,
NumServices: 12, NumServices: 12,
}, },

View File

@ -1559,9 +1559,19 @@ service UserDeviceService {
rpc DeletePushToken(DeletePushTokenRequest) returns (DeletePushTokenResponse); rpc DeletePushToken(DeletePushTokenRequest) returns (DeletePushTokenResponse);
} }
// ListAppsRequest 退 App
message ListAppsRequest {
RequestMeta meta = 1;
}
message ListAppsResponse {
repeated App apps = 1;
}
// AppRegistryService gateway package_name -> app_code // AppRegistryService gateway package_name -> app_code
service AppRegistryService { service AppRegistryService {
rpc ResolveApp(ResolveAppRequest) returns (ResolveAppResponse); rpc ResolveApp(ResolveAppRequest) returns (ResolveAppResponse);
rpc ListApps(ListAppsRequest) returns (ListAppsResponse);
} }
// CountryAdminService App RPC // CountryAdminService App RPC

View File

@ -2556,6 +2556,7 @@ var UserDeviceService_ServiceDesc = grpc.ServiceDesc{
const ( const (
AppRegistryService_ResolveApp_FullMethodName = "/hyapp.user.v1.AppRegistryService/ResolveApp" AppRegistryService_ResolveApp_FullMethodName = "/hyapp.user.v1.AppRegistryService/ResolveApp"
AppRegistryService_ListApps_FullMethodName = "/hyapp.user.v1.AppRegistryService/ListApps"
) )
// AppRegistryServiceClient is the client API for AppRegistryService service. // AppRegistryServiceClient is the client API for AppRegistryService service.
@ -2565,6 +2566,7 @@ const (
// AppRegistryService 是 gateway 解析 package_name -> app_code 的内部入口。 // AppRegistryService 是 gateway 解析 package_name -> app_code 的内部入口。
type AppRegistryServiceClient interface { type AppRegistryServiceClient interface {
ResolveApp(ctx context.Context, in *ResolveAppRequest, opts ...grpc.CallOption) (*ResolveAppResponse, error) ResolveApp(ctx context.Context, in *ResolveAppRequest, opts ...grpc.CallOption) (*ResolveAppResponse, error)
ListApps(ctx context.Context, in *ListAppsRequest, opts ...grpc.CallOption) (*ListAppsResponse, error)
} }
type appRegistryServiceClient struct { type appRegistryServiceClient struct {
@ -2585,6 +2587,16 @@ func (c *appRegistryServiceClient) ResolveApp(ctx context.Context, in *ResolveAp
return out, nil return out, nil
} }
func (c *appRegistryServiceClient) ListApps(ctx context.Context, in *ListAppsRequest, opts ...grpc.CallOption) (*ListAppsResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(ListAppsResponse)
err := c.cc.Invoke(ctx, AppRegistryService_ListApps_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
// AppRegistryServiceServer is the server API for AppRegistryService service. // AppRegistryServiceServer is the server API for AppRegistryService service.
// All implementations must embed UnimplementedAppRegistryServiceServer // All implementations must embed UnimplementedAppRegistryServiceServer
// for forward compatibility. // for forward compatibility.
@ -2592,6 +2604,7 @@ func (c *appRegistryServiceClient) ResolveApp(ctx context.Context, in *ResolveAp
// AppRegistryService 是 gateway 解析 package_name -> app_code 的内部入口。 // AppRegistryService 是 gateway 解析 package_name -> app_code 的内部入口。
type AppRegistryServiceServer interface { type AppRegistryServiceServer interface {
ResolveApp(context.Context, *ResolveAppRequest) (*ResolveAppResponse, error) ResolveApp(context.Context, *ResolveAppRequest) (*ResolveAppResponse, error)
ListApps(context.Context, *ListAppsRequest) (*ListAppsResponse, error)
mustEmbedUnimplementedAppRegistryServiceServer() mustEmbedUnimplementedAppRegistryServiceServer()
} }
@ -2605,6 +2618,9 @@ type UnimplementedAppRegistryServiceServer struct{}
func (UnimplementedAppRegistryServiceServer) ResolveApp(context.Context, *ResolveAppRequest) (*ResolveAppResponse, error) { func (UnimplementedAppRegistryServiceServer) ResolveApp(context.Context, *ResolveAppRequest) (*ResolveAppResponse, error) {
return nil, status.Error(codes.Unimplemented, "method ResolveApp not implemented") return nil, status.Error(codes.Unimplemented, "method ResolveApp not implemented")
} }
func (UnimplementedAppRegistryServiceServer) ListApps(context.Context, *ListAppsRequest) (*ListAppsResponse, error) {
return nil, status.Error(codes.Unimplemented, "method ListApps not implemented")
}
func (UnimplementedAppRegistryServiceServer) mustEmbedUnimplementedAppRegistryServiceServer() {} func (UnimplementedAppRegistryServiceServer) mustEmbedUnimplementedAppRegistryServiceServer() {}
func (UnimplementedAppRegistryServiceServer) testEmbeddedByValue() {} func (UnimplementedAppRegistryServiceServer) testEmbeddedByValue() {}
@ -2644,6 +2660,24 @@ func _AppRegistryService_ResolveApp_Handler(srv interface{}, ctx context.Context
return interceptor(ctx, in, info, handler) return interceptor(ctx, in, info, handler)
} }
func _AppRegistryService_ListApps_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(ListAppsRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(AppRegistryServiceServer).ListApps(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: AppRegistryService_ListApps_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(AppRegistryServiceServer).ListApps(ctx, req.(*ListAppsRequest))
}
return interceptor(ctx, in, info, handler)
}
// AppRegistryService_ServiceDesc is the grpc.ServiceDesc for AppRegistryService service. // AppRegistryService_ServiceDesc is the grpc.ServiceDesc for AppRegistryService service.
// It's only intended for direct use with grpc.RegisterService, // It's only intended for direct use with grpc.RegisterService,
// and not to be introspected or modified (even as a copy) // and not to be introspected or modified (even as a copy)
@ -2655,6 +2689,10 @@ var AppRegistryService_ServiceDesc = grpc.ServiceDesc{
MethodName: "ResolveApp", MethodName: "ResolveApp",
Handler: _AppRegistryService_ResolveApp_Handler, Handler: _AppRegistryService_ResolveApp_Handler,
}, },
{
MethodName: "ListApps",
Handler: _AppRegistryService_ListApps_Handler,
},
}, },
Streams: []grpc.StreamDesc{}, Streams: []grpc.StreamDesc{},
Metadata: "proto/user/v1/user.proto", Metadata: "proto/user/v1/user.proto",

View File

@ -379,6 +379,15 @@ type DebitGiftResponse struct {
HostPointTemplateVersion string `protobuf:"bytes,27,opt,name=host_point_template_version,json=hostPointTemplateVersion,proto3" json:"host_point_template_version,omitempty"` HostPointTemplateVersion string `protobuf:"bytes,27,opt,name=host_point_template_version,json=hostPointTemplateVersion,proto3" json:"host_point_template_version,omitempty"`
// balance_version 是 sender 被扣费资产在本次账务完成后的版本;同 command_id 重放返回首次版本。 // balance_version 是 sender 被扣费资产在本次账务完成后的版本;同 command_id 重放返回首次版本。
BalanceVersion int64 `protobuf:"varint,28,opt,name=balance_version,json=balanceVersion,proto3" json:"balance_version,omitempty"` BalanceVersion int64 `protobuf:"varint,28,opt,name=balance_version,json=balanceVersion,proto3" json:"balance_version,omitempty"`
// recharge_seven_day_coins / recharge_thirty_day_coins 是扣费时由 wallet owner 固化的 UTC 自然日充值快照。
// lucky-gift-service 只能消费这份结算事实做概率分层,不能为了开奖直连钱包库或临时扫描充值流水。
RechargeSevenDayCoins int64 `protobuf:"varint,29,opt,name=recharge_seven_day_coins,json=rechargeSevenDayCoins,proto3" json:"recharge_seven_day_coins,omitempty"`
RechargeThirtyDayCoins int64 `protobuf:"varint,30,opt,name=recharge_thirty_day_coins,json=rechargeThirtyDayCoins,proto3" json:"recharge_thirty_day_coins,omitempty"`
// last_recharged_at_ms 仅在真实 lucky/super_lucky 金币送礼时返回0 表示近 30 个 UTC 自然日没有充值。
LastRechargedAtMs int64 `protobuf:"varint,31,opt,name=last_recharged_at_ms,json=lastRechargedAtMs,proto3" json:"last_recharged_at_ms,omitempty"`
// paid_at_ms 是 wallet_transactions.created_at_ms 的稳定快照;首次扣费与同 command_id 重放必须完全一致。
// room/lucky 只能用该 wallet owner 事实做 UTC 日、小时和充值后短窗归属,不能用 saga 或恢复 worker 时钟替代。
PaidAtMs int64 `protobuf:"varint,32,opt,name=paid_at_ms,json=paidAtMs,proto3" json:"paid_at_ms,omitempty"`
unknownFields protoimpl.UnknownFields unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache sizeCache protoimpl.SizeCache
} }
@ -609,6 +618,34 @@ func (x *DebitGiftResponse) GetBalanceVersion() int64 {
return 0 return 0
} }
func (x *DebitGiftResponse) GetRechargeSevenDayCoins() int64 {
if x != nil {
return x.RechargeSevenDayCoins
}
return 0
}
func (x *DebitGiftResponse) GetRechargeThirtyDayCoins() int64 {
if x != nil {
return x.RechargeThirtyDayCoins
}
return 0
}
func (x *DebitGiftResponse) GetLastRechargedAtMs() int64 {
if x != nil {
return x.LastRechargedAtMs
}
return 0
}
func (x *DebitGiftResponse) GetPaidAtMs() int64 {
if x != nil {
return x.PaidAtMs
}
return 0
}
// DebitGiftTarget 是一笔批量送礼中的单个接收方账务快照。 // DebitGiftTarget 是一笔批量送礼中的单个接收方账务快照。
type DebitGiftTarget struct { type DebitGiftTarget struct {
state protoimpl.MessageState `protogen:"open.v1"` state protoimpl.MessageState `protogen:"open.v1"`
@ -26262,7 +26299,7 @@ const file_proto_wallet_v1_wallet_proto_rawDesc = "" +
"\x15target_host_region_id\x18\v \x01(\x03R\x12targetHostRegionId\x12<\n" + "\x15target_host_region_id\x18\v \x01(\x03R\x12targetHostRegionId\x12<\n" +
"\x1btarget_agency_owner_user_id\x18\f \x01(\x03R\x17targetAgencyOwnerUserId\x12%\n" + "\x1btarget_agency_owner_user_id\x18\f \x01(\x03R\x17targetAgencyOwnerUserId\x12%\n" +
"\x0eentitlement_id\x18\r \x01(\tR\rentitlementId\x12#\n" + "\x0eentitlement_id\x18\r \x01(\tR\rentitlementId\x12#\n" +
"\rcharge_source\x18\x0e \x01(\tR\fchargeSource\"\xff\t\n" + "\rcharge_source\x18\x0e \x01(\tR\fchargeSource\"\xc2\v\n" +
"\x11DebitGiftResponse\x12,\n" + "\x11DebitGiftResponse\x12,\n" +
"\x12billing_receipt_id\x18\x01 \x01(\tR\x10billingReceiptId\x12%\n" + "\x12billing_receipt_id\x18\x01 \x01(\tR\x10billingReceiptId\x12%\n" +
"\x0etransaction_id\x18\x02 \x01(\tR\rtransactionId\x12\x1d\n" + "\x0etransaction_id\x18\x02 \x01(\tR\rtransactionId\x12\x1d\n" +
@ -26294,7 +26331,12 @@ const file_proto_wallet_v1_wallet_proto_rawDesc = "" +
"\x1fhost_point_policy_instance_code\x18\x19 \x01(\tR\x1bhostPointPolicyInstanceCode\x127\n" + "\x1fhost_point_policy_instance_code\x18\x19 \x01(\tR\x1bhostPointPolicyInstanceCode\x127\n" +
"\x18host_point_template_code\x18\x1a \x01(\tR\x15hostPointTemplateCode\x12=\n" + "\x18host_point_template_code\x18\x1a \x01(\tR\x15hostPointTemplateCode\x12=\n" +
"\x1bhost_point_template_version\x18\x1b \x01(\tR\x18hostPointTemplateVersion\x12'\n" + "\x1bhost_point_template_version\x18\x1b \x01(\tR\x18hostPointTemplateVersion\x12'\n" +
"\x0fbalance_version\x18\x1c \x01(\x03R\x0ebalanceVersion\"\xed\x01\n" + "\x0fbalance_version\x18\x1c \x01(\x03R\x0ebalanceVersion\x127\n" +
"\x18recharge_seven_day_coins\x18\x1d \x01(\x03R\x15rechargeSevenDayCoins\x129\n" +
"\x19recharge_thirty_day_coins\x18\x1e \x01(\x03R\x16rechargeThirtyDayCoins\x12/\n" +
"\x14last_recharged_at_ms\x18\x1f \x01(\x03R\x11lastRechargedAtMs\x12\x1c\n" +
"\n" +
"paid_at_ms\x18 \x01(\x03R\bpaidAtMs\"\xed\x01\n" +
"\x0fDebitGiftTarget\x12\x1d\n" + "\x0fDebitGiftTarget\x12\x1d\n" +
"\n" + "\n" +
"command_id\x18\x01 \x01(\tR\tcommandId\x12$\n" + "command_id\x18\x01 \x01(\tR\tcommandId\x12$\n" +

View File

@ -92,6 +92,15 @@ message DebitGiftResponse {
string host_point_template_version = 27; string host_point_template_version = 27;
// balance_version sender command_id // balance_version sender command_id
int64 balance_version = 28; int64 balance_version = 28;
// recharge_seven_day_coins / recharge_thirty_day_coins wallet owner UTC
// lucky-gift-service
int64 recharge_seven_day_coins = 29;
int64 recharge_thirty_day_coins = 30;
// last_recharged_at_ms lucky/super_lucky 0 30 UTC
int64 last_recharged_at_ms = 31;
// paid_at_ms wallet_transactions.created_at_ms command_id
// room/lucky wallet owner UTC saga worker
int64 paid_at_ms = 32;
} }
// DebitGiftTarget // DebitGiftTarget

View File

@ -0,0 +1,306 @@
# 幸运礼物动态策略 V3实现边界与模拟验收
## 1. 版本边界
本文只描述 `dynamic_v3` 的规则、持久化契约和可重复模拟口径,不改变已经发布的 `fixed_v2` 规则语义,也不代表该策略已在线上自动启用。
- `strategy_version=fixed_v2`:继续按历史不可变规则执行;旧请求或旧记录缺少 `strategy_version` 时必须归一为 `fixed_v2`
- `strategy_version=dynamic_v3`:只有显式发布并启用的规则才进入动态水位、充值加权、保底、大奖和六维风控。不能因为服务升级而把存量奖池隐式切换到 V3。
- 新奖池未配置时返回 `dynamic_v3``disabled` 草稿。各 App 必须先补齐累计消费大奖门槛、普通/高阶充值门槛及六项金额上限,才允许启用。
- 启用前还必须确认 user/gateway/room 已升级到由 `auth_sessions.device_id` 签入 access JWT再通过 `room.RequestMeta` 和可恢复 command 原样传递设备作用域。旧 JWT 缺 `device_id``fixed_v2` 仍兼容,`dynamic_v3` 必须 fail-close不得用 `session_id`/`command_id` 伪造设备。
- 启用前还必须确认 user/gateway/room/wallet 已按顺序升级access JWT 和 room meta 携带 auth session 绑定的可信 `device_id`wallet 回执携带交易 `paid_at_ms`、充值快照和 `gift_income_coins`。任一动态字段缺失或真实收礼返币比例与规则 `anchor_rate_ppm` 不一致时运行侧 fail-close。
- 比例、概率和倍率统一使用 ppm`1_000_000=100%=1x`;钱只使用整数金币;业务时间只使用 UTC epoch milliseconds。
- 离线模拟使用固定 seed 的 `math/rand`;生产抽奖必须注入不可预测的安全随机源,不能复用模拟 RNG。
## 2. 已确认的产品口径
### 2.1 资金拆分采用 98/1/1
每笔已成功扣费的幸运礼物按整数守恒拆分:
| 去向 | 默认比例 | 作用 |
| --- | ---: | --- |
| 公共奖金池 `P` | 98% | 在本抽开奖前先入池,承担用户返奖 |
| 平台盈利 | 1% | 只记盈利拆账,不进入可返奖余额 |
| 收礼主播收益 | 1% | 规则计算值必须与钱包已落账回执完全一致,不允许缺字段或其他返币比例静默覆盖规则 |
原图同一行同时出现“抽取 98%”和“92% 可灵活配置”两者冲突。V3 采用能够与 1%+1% 合计为 100% 的 **98/1/1**“92%”视为原图笔误,不进入默认配置、代码或模拟。比例可发布为其他值时,三项仍必须非负且合计 100%,不允许出现去向不明的余额。
公共池与主播份额分别向下取整,无法再拆的整币余数归平台盈利,任何价格都满足:
```text
public_pool_coins + profit_coins + anchor_return_coins = coin_spent
```
### 2.2 RTP 是观察目标,不是透支承诺
- 发布目标为 98%,正常观察带为 95%101%98%±3 个百分点)。
- RTP 分子是已经开奖形成的不可撤销返奖负债,分母是对应已扣费流水;分母为 0 时是“无有效样本”,不能当成 0% 去触发补偿大奖。
- 95%101% 是资金充足且样本量足够时的运营观察目标,不是每抽、每用户、每窗口的硬保证。
- 当奖池、单次限额或任一风控窗口不足时,安全约束优先;允许窗口 `underpaid` 或短期偏离 95%101%,禁止为了追 RTP 透支奖池。
- `settlement_window_wager` 是当前不可变规则版本的真实金币流水阈值。单抽整体计入它开始时所在窗口;若本抽使累计流水达到或超过阈值,下一抽才开启新窗口。`gift_price_reference` 只用于体验阶段的等价抽数,不参与 V3 滚窗。
### 2.3 原图概率表不能作为生产默认概率
原图示例为:
| 倍率 | 概率 | EV 贡献 |
| ---: | ---: | ---: |
| 0x | 60% | 0x |
| 5x | 20% | 1x |
| 50x | 15% | 7.5x |
| 200x | 4% | 8x |
| 1000x | 1% | 10x |
| 合计 | 100% | **26.5x** |
因此该表的名义 `EV=26.5x`,即 `RTP=2650%`,不可能同时是 98% RTP。它只用于验证原图的 `P/W` 删档、删 0 和重抽分支,不能写入生产默认基础奖档。
`dynamic_v3` 的安全发布草稿采用独立的 98% 静态 EV`0x@5% + 0.5x@4% + 1x@86% + 2x@5% = 0.98x``200x/500x/1000x` 作为大奖集合单独配置,不能在缺少经过成本校验的概率时照搬进基础概率表。
## 3. 一次抽奖的确定顺序
一次真实抽奖按以下顺序完成;同一事务内锁住的状态必须与 draw record 一起提交:
1. `user-service``auth_sessions.device_id` 签入 access JWTgateway 验签后写入 room `RequestMeta`room saga 固化该设备快照,禁止用可轮换的 session 或 command ID 代替。
2. `wallet-service` 完成真实扣费,并在幂等回执中固化 `coin_spent`、交易 `paid_at_ms`、最近 7/30 个 UTC 自然日充值额、最后充值时间及主播收益金额。
3. `lucky-gift-service` 只消费上述可信事实不直连钱包库不按当前余额、IP、服务时区、恢复时间或客户端自报值推断充值画像和时间归属。
4. 本抽金币按 98/1/1 拆分;公共池份额先计入 `P`,再比较本抽奖金 `W`
5. 按 `app_code + pool_id + rule_version` 读取不可变规则锁定公共池、用户日状态、72 小时桶/边界事件、连 0 状态和六维风控计数。
6. 用钱包快照选择充值层级,再对所有非 0 基础档应用水位和最近充值因子0 档接收剩余概率。
7. 优先处理已持久化的“消费里程碑大奖 token”其次判断 RTP 补偿大奖,最后进入普通概率抽奖。
8. 对选中的 `W` 依次执行奖池、单用户日大奖次数和六维风控检查;任何一项不满足都不能发奖。
9. 写入抽奖事实、决策快照、奖池/窗口/风控/用户状态及 outbox。钱包返奖状态独立收敛为 `pending/granted/failed`
这套顺序的核心约束是:先有已落账的扣费事实,再入池,再开奖;任何体验规则都不能制造不存在的资金。
## 4. 动态概率、分层和保底
### 4.1 充值分层
V3 固定有 `novice/normal/advanced` 三层,但两个门槛由每个不可变规则版本配置:
- 最近 7 日和最近 30 日均按 UTC 自然日统计且包含当天;分别覆盖“今天及前 6 个 UTC 日”和“今天及前 29 个 UTC 日”,上界为钱包扣费时刻。
- 用户只在同时满足某层的 7 日和 30 日最低充值门槛时才进入该层;不能只满足其中一维就越级。
- `novice` 是无条件兜底层,它的 `0/0` 只是协议和存储 sentinel不表示“只有充值为 0 才是新手”。若 `7d < normal_7d` **或** `30d < normal_30d`,即使两个充值值都非零,用户仍属于 `novice`
- `normal` 启用前至少一维必须为正数;`advanced` 两维均不得低于 `normal`且至少一维严格更高。disabled 草稿可保留 `0/0` 表示尚未配置,但不能以该状态启用。
- 阶段阈值相等时算已达到,即使用 `>=`;两维都等于普通门槛时进入 `normal`。所有金额门槛按 App 金币口径显式发布。
充值快照由 `wallet-service` 在礼物扣费事务中查询并写入交易 metadata同一 `command_id` 重放返回首次快照,避免重试时跨层或丢失五分钟加成。
### 4.2 水位和最近充值因子
设某层的非 0 档基础权重为 `base_weight`,则:
```text
adjusted_weight = base_weight × water_factor × recharge_factor
```
因子是相对乘法,不是把百分点直接相加:
| 条件 | 默认因子 | 边界 |
| --- | ---: | --- |
| `P < low_watermark` | 0.70 | 恰等于低水位时不下调 |
| `low_watermark <= P <= high_watermark` | 1.00 | 两个端点都属于中水位 |
| `P > high_watermark` | 1.30 | 恰等于高水位时不上调 |
| 有可信充值事实且处于充值后窗口 | 1.10 | `[last_recharged_at_ms, last_recharged_at_ms + 300000)` |
例如低水位并处于充值后五分钟时,非 0 权重为 `base × 0.70 × 1.10 = base × 0.77`,不是 `base × 0.80`。五分钟窗口包含起点和第 `299999ms`,不包含第 `300000ms`;未来时间、缺失充值事实或 `last_recharged_at_ms=0` 都不加权。
调整后:若正奖权重和不超过 100%0 档权重取剩余值;若正奖权重和超过 100%,按相同比例归一化正奖权重并把 0 档置为 0。该归一化只解决概率守恒不绕过资金和风控。
### 4.3 连续 5 次 0 后的第 6 抽
- `loss_streak` 只在最终可见返奖为 0 时加一,任意正返奖后清零。
- 前 5 次连续为 0 后,第 6 抽从普通候选中排除 0 档,选择可支付的非 0 档。
- “第 6 抽不出 0”是有资金、有剩余额度时的体验保证如果所有正奖都超过 `P` 或六维风控剩余额度,仍返回 0并记录 `miss_protection_blocked_no_payable_tier`。不得透支来兑现保底。
## 5. `P/W` 安全重抽
`P` 是本抽入池后的公共奖金池可用余额,`W=gift_price × multiplier`。普通抽奖执行:
1. 第一次按当前动态权重抽档。
2. `W <= P` 且所有风控均允许时直接支付;`W == P` 可以支付,支付后 `P=0`
3. 若 `W > P`,同时删除该中奖档和 0 档,再对剩余正奖档重抽。
4. 重抽仍有 `W > P` 时继续删除该中奖档0 档只在第一次资金不足时删除一次。
5. 若最终没有可支付的正奖档,安全回退 0不改变 `P` 为负数。
原图 `P=800`、单价 10 的脚本化示例只用于分支验收:先抽到 `200x(W=2000)` 时删除 `200x+0x`;若再抽到 `1000x(W=10000)`,再删除 `1000x`;随后只能在仍有权重且 `W<=800` 的正奖中选择。该示例证明重抽语义,不证明原图概率具有可用 RTP。
奖池不足、风险额度不足和个人大奖次数达到上限是不同的审计原因。只有 `W>P` 触发原图规定的“删中奖档并同时删 0”其他风控拒绝不能伪装成奖池不足。
## 6. 两种大奖机制
大奖集合由 `jackpot_multiplier_ppms` 配置,默认产品集合为 `200x/500x/1000x`。原图没有给出三个大奖之间的概率,因此当前不可变规则明确采用“在可支付集合中等权随机”;这不属于普通基础概率。若产品需要非等权,必须先新增显式权重契约和成本验收,不能从普通奖档概率猜算。
### 6.1 机制一RTP 亏损补偿大奖
本抽开始时同时满足以下条件,才允许从当前可支付大奖集合随机选择:
- 全局结算 RTP `<= jackpot_global_rtp_max_ppm`,默认 98%
- 用户当前 UTC 日 RTP `<= jackpot_user_day_rtp_max_ppm`,默认 96%
- 用户滚动 72 小时 RTP `<= jackpot_user_72h_rtp_max_ppm`,默认 96%
- 用户本 UTC 日大奖次数 `< max_jackpot_hits_per_user_day`,默认 5
- 候选 `W<=P` 且不超过六维风控的最小剩余额度。
任一 RTP 分母为 0、任一 RTP 高于阈值、奖池不足或风控不足,都只回到普通抽奖,不强发大奖。滚动 72 小时是 `[now_ms-72h, now_ms)` 的 UTC 业务窗口,不能替换成“最近 3 个本地自然日”。
### 6.2 机制二:消费里程碑大奖
- `jackpot_spend_threshold_coins` 是当前不可变规则版本动态配置的“用户 UTC 日累计消费金币门槛”。它不绑定任何固定法币金额,服务也不读取实时汇率推断。
- 用户 UTC 日累计消费每跨过一个完整门槛,持久化一个大奖 token同一流水重试不能重复发 token。消费进度和当日命中次数在 UTC 日切换时归零,但已赚到且因资金不足未消费的 token 保存在用户/奖池状态中,可跨日等待下一抽。
- 当前抽跨过的门槛只能影响下一抽,不能回头改变已经完成的当前抽。
- 下一抽优先消费已有 token若大奖集合因 `P`、日 5 次上限或风控不可支付token 保留,用户仍获得本次普通抽奖。
- 同一抽同时满足机制一和机制二时,已承诺的里程碑 token 优先;一个 draw 最多结算一个最终奖档。
原图写的是“定时任务”。实现改为在 owner 事务里按已结算送礼事实同步计算跨过的整数门槛并落 token结果仍只影响下一抽但不会出现“用户已经完成门槛、下一抽先于 cron 扫描到达”的竞态,也不需要 cron-service 回扫明细或直接拥有大奖状态。这个事件驱动实现是有意的工程等价替换;若产品需要固定延迟发放,必须另增明确生效时间字段,而不是依赖不确定的扫描周期。
## 7. 六维风控与时间口径
所有启用的 `dynamic_v3` 规则必须配置正数上限:
| 维度 | 配置字段 | UTC 窗口 |
| --- | --- | --- |
| 单次返奖 | `max_single_payout` | 当前 draw |
| 用户小时 | `user_hourly_payout_cap` | 当前 UTC 小时 |
| 用户日 | `user_daily_payout_cap` | 当前 UTC 自然日 |
| 设备日 | `device_daily_payout_cap` | 当前 UTC 自然日 |
| 房间小时 | `room_hourly_payout_cap` | 当前 UTC 小时 |
| 主播日 | `anchor_daily_payout_cap` | 当前 UTC 自然日 |
一次候选的有效容量是 `P` 和六项剩余额度的最小值。候选金额等于剩余额度时允许,超过任一维度即拒绝;计数更新与 draw record 必须在同一锁定事务内,避免并发抽奖分别看到同一份余额。
设备日 scope 只接受 user-service 已落库 session 的 `device_id`,由签名 JWT 贯通到 room 和 lucky旧 token/旧 command 缺字段时 `fixed_v2` 可继续重放,但 `dynamic_v3` 必须拒绝开奖,不能回退到 `session_id` 或每单变化的 `command_id`。外部 App 的 device ID 由已认证调用方负责稳定提供V3 缺失同样拒绝;服务只保存 App 内稳定 hash避免暴露原始标识。
所有时间范围遵守 `[start_ms,end_ms)`包含开始毫秒不包含结束毫秒。UTC 日、UTC 小时、充值 5 分钟和滚动 72 小时都不得使用 `time.Local`、容器时区或客户端时区切分。
## 8. 批量 N 次的语义
`gift_count=N` 表示按顺序执行 N 次抽奖,不是把总金额当成一次抽奖,也不是“只开奖一次但保证中奖”。例如 `gift_count=99` 必须产生 99 次状态推进。
- 总 `coin_spent` 拆成 N 个整数单份,余数从前往后每份加 1三资金桶使用联合累计配额保证每一抽 `public_i+profit_i+anchor_i=unit_spent_i`,同时整批三桶精确等于钱包回执总拆账。
- 第 `i` 抽看到第 `i-1` 抽后的 `P`、连 0、RTP、token、日大奖次数和风控计数不能并行抽 N 次后再合并。
- 若第 `i` 抽使当前 RTP 窗口真实累计流水达到或超过 `settlement_window_wager`,第 `i+1` 抽必须先滚到新窗口;不能按 `ceil(window_wager/gift_price_reference)` 折算抽数,也不能把整批强塞进旧窗口后再一次性关窗。
- 每个子抽用稳定子幂等键(现有约定为 `command_id#序号`)保留独立 draw record重放整批不能跳过、补写或重复支付其中一抽。
- 一批内共享状态只锁一次并按顺序在内存推进,最终批量持久化;性能优化不能改变顺序语义。
- 聚合钱包返奖和房间表现可以按送礼命令合并,但审计仍保留 N 条单抽事实。
外部 App 的 `/lucky-gifts/send` 也遵守同一语义:`dynamic_v3` 逐次执行最多 999 抽、写 `external_lucky_gift_draw_items`,最后只把总奖励聚合返回;外部调用方自行入账,因此不生成 HyApp wallet 返奖 outbox。外部 `dynamic_v3` 必须提交稳定 `device_id` 和真实扣费 `paid_at_ms`,同设备多账号共享设备日上限;但 luck-gateway 目前只有 App allowlist不能独立证明设备真实性所以调用方必须在自身认证/请求签名边界内绑定这些值owner 不得用 `external_user_id``request_id` 或请求到达时间兜底。当前外部协议没有可验证的充值 owner 快照,运行侧明确按 `novice` 且不加最近充值因子,不能从 `metadata` 猜充值额。`fixed_v2` 外部请求继续保持缺少新事实的历史兼容路径。
## 9. 接口和配置字段
### 9.1 事实输入
`wallet.v1.DebitGiftResponse` 新增以下幂等回执字段:
- `recharge_seven_day_coins`
- `recharge_thirty_day_coins`
- `last_recharged_at_ms`
- `paid_at_ms`
`luckygift.v1.LuckyGiftMeta` 接收:
- `device_id`(从已验签 access JWT 穿过 room command 恢复链路)
- `paid_at_ms`(逐目标 wallet transaction 的稳定创建时间)
- `recharge_7d_coins`
- `recharge_30d_coins`
- `last_recharged_at_ms`
- `gift_income_coins`
`room.v1.RequestMeta.device_id` 只由 gateway 从验签 JWT 的 `device_id` claim 写入;`ExternalGiftDrawRequest.device_id` 由外部认证调用方承担稳定性责任。V3 对两个入口都不提供 session/command fallback。其余结算字段只来自 wallet owner 的成功回执。`request_id` 只做链路追踪;抽奖幂等仍使用 `command_id`
### 9.2 不可变规则字段
`LuckyGiftRuleConfig` / `lucky_gift_rule_versions` 的 V3 字段分组如下:
| 决策 | 字段 |
| --- | --- |
| 版本和目标 | `strategy_version`, `target_rtp_ppm`, `settlement_window_wager`, `control_band_ppm`, `gift_price_reference` |
| 资金拆分 | `pool_rate_ppm`, `profit_rate_ppm`, `anchor_rate_ppm`, `initial_pool_coins` |
| 保底和水位 | `loss_streak_guarantee`, `low_watermark_coins`, `low_water_nonzero_factor_ppm`, `high_watermark_coins`, `high_water_nonzero_factor_ppm` |
| 最近充值 | `recharge_boost_window_ms`, `recharge_boost_factor_ppm` |
| 大奖 | `jackpot_multiplier_ppms`, `jackpot_global_rtp_max_ppm`, `jackpot_user_day_rtp_max_ppm`, `jackpot_user_72h_rtp_max_ppm`, `jackpot_spend_threshold_coins`, `max_jackpot_hits_per_user_day` |
| 六维风控 | `max_single_payout`, `user_hourly_payout_cap`, `user_daily_payout_cap`, `device_daily_payout_cap`, `room_hourly_payout_cap`, `anchor_daily_payout_cap` |
| 充值层级 | `stages[].min_recharge_7d_coins`, `stages[].min_recharge_30d_coins`, `stages[].tiers[]` |
后台沿用现有 `/admin/activity/lucky-gifts/v2/config` 配置入口URL 中的 `v2` 是接口兼容标识,不等于 `strategy_version`。发布时必须把上述字段完整映射到内部 protobuf、domain 和 MySQL不允许只在 HTTP JSON 中临时扩字段。
## 10. 持久化与审计
| 表 | V3 事实 |
| --- | --- |
| `lucky_gift_rule_versions` | `fixed_v2/dynamic_v3` 不可变版本、资金拆分、水位、大奖门槛和六项上限 |
| `lucky_gift_stage_tiers` | 普通/高阶充值门槛、新手兜底 sentinel、基础倍率和基础概率 |
| `lucky_pools` | 公共池 `balance/total_in/total_out`,以及 `profit_total/anchor_income_total/initial_seed_total` |
| `lucky_rtp_windows` | 全局/奖池窗口的流水、目标返奖、实际返奖和 `open/closed/underpaid` 状态 |
| `lucky_user_rtp_hour_buckets` | 用户 UTC 小时流水/返奖桶,用于严格滚动 72 小时 |
| `lucky_user_rtp_boundary_events` | 只补齐 `[now-72h,now)` 首尾两个不足一小时的片段;与完整小时桶组合后不近似、不回扫全量 draw |
| `lucky_user_strategy_days` | 用户 UTC 日流水、返奖、消费和已命中大奖次数 |
| `lucky_user_states` | 累计抽数、等价流水、`loss_streak` 和可跨日保留的待消费 token |
| `lucky_risk_counters` | 用户/设备/房间/主播的 UTC 小时或日累计返奖 |
| `lucky_draw_records` | 单抽最终奖档、金额、规则版本、候选/奖池/RTP 决策快照和钱包状态 |
| `external_lucky_gift_draw_items` | 外部 App `gift_count=N` 的 N 条顺序子抽事实;外部聚合行只负责幂等响应 |
| `lucky_gift_command_locks` | 内部 `app_code+command_id` 首次并发互斥;等待者在首事务提交后回读同一批 draw 事实 |
| `lucky_gift_outbox` | 钱包补偿与投递审计,不替代 draw 业务事实 |
每条 V3 draw 的审计快照至少要能还原:阶段、规则版本、抽前池余额、本抽入池、可用池、六维最小容量、各档基础/调整后权重及因子、原始档、每次删档/重抽、三项 RTP 条件、大奖机制、token 产生/消费/保留、最终原因和阻断原因。
状态边界:
- `reward_status=pending/granted/failed` 只表示自动钱包入账状态;抽奖一旦向用户展示就形成不可撤销负债,`failed` 表示仍欠用户、必须重放或人工补偿,不能反向释放公共池/RTP/风控额度,也不能取消奖励。已 `granted` 后 IM 失败不能把账务事实改成 `failed`
- outbox 使用 `pending/retryable/delivering/delivered/failed`;失败原因、重试次数和锁到期时间必须可查。
- 规则不可变发布;事故回放必须读取 draw 当时的 `rule_version` 和快照,不能套用最新配置重算。
## 11. 本地模拟矩阵
模拟必须调用与生产相同的纯策略内核,并使用固定 seed 输出人类摘要和 JSON。以下每项都是独立验收不用一个长跑 RTP 数字替代边界测试:
| 组 | 输入矩阵 | 必须证明 |
| --- | --- | --- |
| 名义 EV | 原图 0/5/50/200/1000 概率 | `26.5x/2650%``passes_target=false` 是预期成功 |
| 资金拆分 | 0、1、极小金额、普通金额 | 三份非负且逐笔整数守恒;冷启动只注资一次 |
| 原图 P/W | `P=800, price=10`,脚本依次命中 5x、200x、1000x | 5x 直付200x 删除自身+01000x 继续删除;最终不透支 |
| P/W 边界 | `W=P-1/P/P+1`、全部正奖不可付 | 等于可付,大于重抽,候选耗尽回 0`P` 永不为负 |
| 连 0 保底 | streak 05第 6 抽资金足/不足 | 第 6 抽排除 0无可支付正奖时安全回 0并留阻断原因 |
| 水位 | `low-1/low/low+1/high-1/high/high+1` | 只在 `<low` 下调、`>high` 上调,端点为中水位 |
| 充值窗口 | 起点、`+299999ms``+300000ms`、未来时间、无回执 | 严格 `[start,end)`,只有可信快照生效 |
| 因子组合 | 低/中/高水位 × 充值开/关 | 因子相乘;概率合计始终 100% |
| 充值分层 | 各 7/30 日门槛的下 1、等于、上 1 | 必须同时达到两维,选择最高满足层 |
| 批量 | N=1、N=99、金额有余数 | 恰好 N 次顺序推进,金额守恒,单抽事实与重放一致 |
| 结算窗口 | 阈值 99、参考价 10、已 10 抽但仅 98 流水,再执行 99 份共 100 金币 | 第 1 抽进入旧窗并使流水到 100后 98 抽进入新窗;证明不按抽数提前滚动 |
| 大奖机制一 | 全局/当日/72h 各自通过和失败、分母 0 | 三门同时通过才有资格,零样本不触发 |
| 大奖机制二 | 门槛前、恰好跨过、一次跨多个门槛、资金不足 | token 只影响下一抽,幂等产生,资金不足保留 |
| 大奖集合 | 200x/500x/1000x 分别可付/不可付 | 只在可付集合随机,不能选中后透支 |
| 日 5 次 | 已中 4 次、5 次UTC 日边界 | 第 5 次可中,第 6 次大奖被阻断,新 UTC 日重新计数 |
| 六维风控 | 每一维分别取 `W=remaining``W=remaining+1` | 等于允许,超过任一维拒绝,原因可审计 |
| 组合优先级 | token + RTP 补偿 + 连 0 + 低水位 + 充值 | token 优先,其后 RTP再普通安全门永远最后裁决 |
| 并发/幂等 | 同 command 重放、同用户并发状态版本 | 只结算一次;状态无丢失更新,奖池不为负 |
| 有资金长跑 | 固定 seed、足量冷启动资金、各充值层 | 报告实际 RTP/命中率/池底,不把 95%101%当资金不足时硬断言 |
### 11.1 可重复命令
```bash
go test ./services/lucky-gift-service/internal/domain/luckygift
go test ./services/wallet-service/internal/storage/mysql -run 'LuckyGiftRechargeSnapshot'
go run ./services/lucky-gift-service/cmd/strategy-sim \
-seed 20260712 \
-monte-carlo-draws 100000 \
-long-run-draws 100000 \
-group-long-run-draws 30000 \
-json-out /tmp/lucky-gift-dynamic-v3-simulation.json
```
策略模拟进程只有在全部场景满足断言时退出 0。总报告必须同时满足
- `nominal_probability_table.expected_multiplier=26.5`
- `nominal_probability_table.nominal_rtp_percent=2650`
- `nominal_probability_table.passes_target=false`
- `all_passed=true`
- 所有场景输出固定 seed、输入、最终奖档/状态、池底和阻断原因,不能只打印“通过”。
## 12. 上线前验收门
1. 存量规则空 `strategy_version` 读取和重放仍为 `fixed_v2`,且无需补 V3 金额字段。
2. 新 `dynamic_v3` 启用规则的三项拆分必须合计 100%,每层基础概率必须合计 100%,静态 EV 必须落在目标 RTP±控制带内。
3. 累计消费大奖门槛、低/高水位、普通/高阶充值门槛、大奖集合、日次数和六项风控都必须按 App 明确配置;不能依赖代码默认值直接上线。
4. user JWT、gateway/room meta、wallet 回执、lucky-gift 请求、protobuf 生成代码、配置读写和 MySQL schema 必须端到端包含新增字段;动态入口缺可信 `device_id` 或 owner `paid_at_ms` 必须 fail-close。
5. 本地单测、完整模拟、`make proto``make test` 通过后,仍需用隔离奖池灰度;灰度验收重点是奖池不为负、幂等不重复发奖、审计可回放,而不是短窗口 RTP 恰好等于 98%。

File diff suppressed because it is too large Load Diff

View File

@ -17,6 +17,7 @@ type Client interface {
ListLuckyGiftDraws(ctx context.Context, req *luckygiftv1.ListLuckyGiftDrawsRequest) (*luckygiftv1.ListLuckyGiftDrawsResponse, error) ListLuckyGiftDraws(ctx context.Context, req *luckygiftv1.ListLuckyGiftDrawsRequest) (*luckygiftv1.ListLuckyGiftDrawsResponse, error)
GetLuckyGiftDrawSummary(ctx context.Context, req *luckygiftv1.GetLuckyGiftDrawSummaryRequest) (*luckygiftv1.GetLuckyGiftDrawSummaryResponse, error) GetLuckyGiftDrawSummary(ctx context.Context, req *luckygiftv1.GetLuckyGiftDrawSummaryRequest) (*luckygiftv1.GetLuckyGiftDrawSummaryResponse, error)
ListLuckyGiftPoolBalances(ctx context.Context, req *luckygiftv1.ListLuckyGiftPoolBalancesRequest) (*luckygiftv1.ListLuckyGiftPoolBalancesResponse, error) ListLuckyGiftPoolBalances(ctx context.Context, req *luckygiftv1.ListLuckyGiftPoolBalancesRequest) (*luckygiftv1.ListLuckyGiftPoolBalancesResponse, error)
AdjustLuckyGiftPoolBalance(ctx context.Context, req *luckygiftv1.AdjustLuckyGiftPoolBalanceRequest) (*luckygiftv1.AdjustLuckyGiftPoolBalanceResponse, error)
} }
type GRPCClient struct { 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) { func (c *GRPCClient) ListLuckyGiftPoolBalances(ctx context.Context, req *luckygiftv1.ListLuckyGiftPoolBalancesRequest) (*luckygiftv1.ListLuckyGiftPoolBalancesResponse, error) {
return c.client.ListLuckyGiftPoolBalances(ctx, req) 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

@ -25,6 +25,7 @@ import (
const ( const (
adapterBaishunV1 = "baishun_v1" adapterBaishunV1 = "baishun_v1"
adapterLeaderCCV1 = "leadercc_v1"
adapterZeeOneV1 = "zeeone_v1" adapterZeeOneV1 = "zeeone_v1"
adapterVivaGamesV1 = "vivagames_v1" adapterVivaGamesV1 = "vivagames_v1"
adapterReyouV1 = "reyou_v1" adapterReyouV1 = "reyou_v1"
@ -134,6 +135,8 @@ func fetchProviderGameSyncPlan(ctx context.Context, platform *gamev1.GamePlatfor
switch strings.ToLower(strings.TrimSpace(platform.GetAdapterType())) { switch strings.ToLower(strings.TrimSpace(platform.GetAdapterType())) {
case adapterBaishunV1: case adapterBaishunV1:
return fetchBaishunGameSyncPlan(ctx, platform, req) return fetchBaishunGameSyncPlan(ctx, platform, req)
case adapterLeaderCCV1:
return fetchLeaderCCGameSyncPlan(ctx, platform, req)
case adapterZeeOneV1: case adapterZeeOneV1:
return fetchZeeOneGameSyncPlan(platform, req) return fetchZeeOneGameSyncPlan(platform, req)
case adapterVivaGamesV1: case adapterVivaGamesV1:
@ -184,6 +187,21 @@ type baishunSyncConfig struct {
GameURLs map[string]string `json:"game_urls"` GameURLs map[string]string `json:"game_urls"`
} }
type leaderCCSyncConfig struct {
// game_list_url 是灵仙按环境提供的公开 JSON 清单game_urls 由同步操作自动回写。
GameListURL string `json:"game_list_url"`
GameListURLCamel string `json:"gameListUrl"`
}
type leaderCCGameListItem struct {
GameID any `json:"gameId"`
Name string `json:"name"`
Title string `json:"title"`
Version any `json:"ver"`
FullURL string `json:"full_url"`
HalfURL string `json:"half_url"`
}
type zeeoneSyncConfig struct { type zeeoneSyncConfig struct {
// ZeeOne 测试资料给的是逐游戏 H5 地址,不是列表 API后台“获取游戏列表”直接把这个映射转换为候选目录。 // ZeeOne 测试资料给的是逐游戏 H5 地址,不是列表 API后台“获取游戏列表”直接把这个映射转换为候选目录。
GameURLs map[string]string `json:"game_urls"` GameURLs map[string]string `json:"game_urls"`
@ -232,6 +250,91 @@ type yomiSyncConfig struct {
GameCovers map[string]string `json:"game_covers"` GameCovers map[string]string `json:"game_covers"`
} }
func fetchLeaderCCGameSyncPlan(ctx context.Context, platform *gamev1.GamePlatform, req syncGamesRequest) (providerGameSyncPlan, error) {
config, err := decodeLeaderCCSyncConfig(platform.GetAdapterConfigJson())
if err != nil {
return providerGameSyncPlan{}, err
}
endpoint := strings.TrimSpace(firstNonEmpty(config.GameListURL, config.GameListURLCamel))
if endpoint == "" {
return providerGameSyncPlan{}, fmt.Errorf("灵仙列表同步缺少配置: adapterConfigJson.game_list_url")
}
httpReq, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return providerGameSyncPlan{}, fmt.Errorf("灵仙游戏列表 URL 不合法: %w", err)
}
resp, err := (&http.Client{Timeout: 10 * time.Second}).Do(httpReq)
if err != nil {
return providerGameSyncPlan{}, fmt.Errorf("请求灵仙游戏列表失败: %w", err)
}
defer resp.Body.Close()
raw, err := io.ReadAll(resp.Body)
if err != nil {
return providerGameSyncPlan{}, fmt.Errorf("读取灵仙游戏列表失败: %w", err)
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return providerGameSyncPlan{}, fmt.Errorf("灵仙游戏列表 HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(raw)))
}
var providerItems []leaderCCGameListItem
if err := json.Unmarshal(raw, &providerItems); err != nil {
return providerGameSyncPlan{}, fmt.Errorf("解析灵仙游戏列表失败: %w", err)
}
games, gameURLs := leaderCCCatalogItems(platform.GetPlatformCode(), providerItems, req)
if len(games) == 0 {
return providerGameSyncPlan{}, fmt.Errorf("灵仙游戏列表为空")
}
return providerGameSyncPlan{Games: games, GameURLs: gameURLs}, nil
}
func decodeLeaderCCSyncConfig(raw string) (leaderCCSyncConfig, error) {
if strings.TrimSpace(raw) == "" {
return leaderCCSyncConfig{}, nil
}
var config leaderCCSyncConfig
if err := json.Unmarshal([]byte(raw), &config); err != nil {
return leaderCCSyncConfig{}, fmt.Errorf("灵仙适配器配置 JSON 不合法")
}
return config, nil
}
func leaderCCCatalogItems(platformCode string, providerItems []leaderCCGameListItem, req syncGamesRequest) ([]catalogRequest, map[string]string) {
items := make([]catalogRequest, 0, len(providerItems))
gameURLs := make(map[string]string, len(providerItems))
for index, providerItem := range providerItems {
providerGameID := stringFromJSONValue(providerItem.GameID)
if providerGameID == "" {
continue
}
// 半屏地址优先匹配语音房内嵌场景;厂商未提供时才回退全屏地址。
launchURL := firstNonEmpty(providerItem.HalfURL, providerItem.FullURL)
if launchURL == "" {
continue
}
launchMode := strings.TrimSpace(req.LaunchMode)
if launchMode == "" {
launchMode = "half_screen"
if strings.TrimSpace(providerItem.HalfURL) == "" {
launchMode = defaultLaunchMode
}
}
gameURLs[providerGameID] = launchURL
items = append(items, catalogRequest{
GameID: stableGameID(platformCode, providerGameID),
PlatformCode: strings.TrimSpace(platformCode),
ProviderGameID: providerGameID,
GameName: firstNonEmpty(providerItem.Name, providerItem.Title, providerGameID),
Category: defaulted(req.Category, defaultGameCategory),
LaunchMode: launchMode,
Orientation: defaultOrientation,
MinCoin: req.MinCoin,
Status: defaulted(req.Status, defaultGameStatus),
SortOrder: int32((index + 1) * 10),
Tags: compactTags(append([]string{strings.TrimSpace(platformCode), adapterLeaderCCV1}, req.Tags...)),
})
}
return items, gameURLs
}
func fetchZeeOneGameSyncPlan(platform *gamev1.GamePlatform, req syncGamesRequest) (providerGameSyncPlan, error) { func fetchZeeOneGameSyncPlan(platform *gamev1.GamePlatform, req syncGamesRequest) (providerGameSyncPlan, error) {
config, err := decodeZeeOneSyncConfig(platform.GetAdapterConfigJson()) config, err := decodeZeeOneSyncConfig(platform.GetAdapterConfigJson())
if err != nil { if err != nil {

View File

@ -72,6 +72,42 @@ func TestFetchBaishunGameSyncPlanFetchesSignsAndFormatsCatalog(t *testing.T) {
} }
} }
func TestFetchLeaderCCGameSyncPlanReadsRemoteJSONList(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet || r.URL.Path != "/test_game_list.json" {
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`[
{"gameId":"9","name":"GREEDY BOX","title":"过关斩将","ver":11,"half_url":"https://games.test/greedy_half/index.html"},
{"gameId":"7","name":"SuperRichGod","title":"超级财神","ver":11,"full_url":"https://games.test/rich/index.html"}
]`))
}))
defer server.Close()
plan, err := fetchProviderGameSyncPlan(t.Context(), &gamev1.GamePlatform{
PlatformCode: "lingxian",
AdapterType: adapterLeaderCCV1,
AdapterConfigJson: `{"game_list_url":` + strconv.Quote(server.URL+"/test_game_list.json") + `}`,
}, syncGamesRequest{})
if err != nil {
t.Fatalf("fetchProviderGameSyncPlan failed: %v", err)
}
if len(plan.Games) != 2 {
t.Fatalf("games len = %d, want 2: %+v", len(plan.Games), plan.Games)
}
first, second := plan.Games[0], plan.Games[1]
if first.GameID != "lingxian_9" || first.GameName != "GREEDY BOX" || first.LaunchMode != "half_screen" || first.Status != "disabled" {
t.Fatalf("first game mismatch: %+v", first)
}
if second.GameID != "lingxian_7" || second.LaunchMode != "full_screen" {
t.Fatalf("full URL fallback mismatch: %+v", second)
}
if plan.GameURLs["9"] != "https://games.test/greedy_half/index.html" || plan.GameURLs["7"] != "https://games.test/rich/index.html" {
t.Fatalf("game URLs mismatch: %+v", plan.GameURLs)
}
}
func TestFetchZeeOneGameSyncPlanReadsConfiguredGameURLs(t *testing.T) { func TestFetchZeeOneGameSyncPlanReadsConfiguredGameURLs(t *testing.T) {
plan, err := fetchProviderGameSyncPlan(t.Context(), &gamev1.GamePlatform{ plan, err := fetchProviderGameSyncPlan(t.Context(), &gamev1.GamePlatform{
PlatformCode: "zeeone", PlatformCode: "zeeone",

View File

@ -14,6 +14,8 @@ import (
luckygiftv1 "hyapp.local/api/proto/luckygift/v1" luckygiftv1 "hyapp.local/api/proto/luckygift/v1"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"google.golang.org/grpc/codes"
grpcstatus "google.golang.org/grpc/status"
) )
type Handler struct { type Handler struct {
@ -32,15 +34,38 @@ func New(luckyGift luckygiftadmin.Client, requestTimeout time.Duration, audit sh
type configRequest struct { type configRequest struct {
AppCode string `json:"app_code"` AppCode string `json:"app_code"`
PoolID string `json:"pool_id"` PoolID string `json:"pool_id"`
StrategyVersion string `json:"strategy_version"`
Enabled bool `json:"enabled"` Enabled bool `json:"enabled"`
TargetRTPPercent float64 `json:"target_rtp_percent"` TargetRTPPercent float64 `json:"target_rtp_percent"`
PoolRatePercent float64 `json:"pool_rate_percent"` PoolRatePercent float64 `json:"pool_rate_percent"`
ProfitRatePercent float64 `json:"profit_rate_percent"`
AnchorRatePercent float64 `json:"anchor_rate_percent"`
SettlementWindowWager int64 `json:"settlement_window_wager"` SettlementWindowWager int64 `json:"settlement_window_wager"`
ControlBandPercent float64 `json:"control_band_percent"` ControlBandPercent float64 `json:"control_band_percent"`
GiftPriceReference int64 `json:"gift_price_reference"` GiftPriceReference int64 `json:"gift_price_reference"`
NoviceMaxEquivalentDraws int64 `json:"novice_max_equivalent_draws"` NoviceMaxEquivalentDraws int64 `json:"novice_max_equivalent_draws"`
NormalMaxEquivalentDraws int64 `json:"normal_max_equivalent_draws"` NormalMaxEquivalentDraws int64 `json:"normal_max_equivalent_draws"`
EffectiveFromMS int64 `json:"effective_from_ms"` EffectiveFromMS int64 `json:"effective_from_ms"`
InitialPoolCoins int64 `json:"initial_pool_coins"`
LossStreakGuarantee int64 `json:"loss_streak_guarantee"`
LowWatermarkCoins int64 `json:"low_watermark_coins"`
LowWaterNonzeroFactorPercent float64 `json:"low_water_nonzero_factor_percent"`
HighWatermarkCoins int64 `json:"high_watermark_coins"`
HighWaterNonzeroFactorPercent float64 `json:"high_water_nonzero_factor_percent"`
RechargeBoostWindowMS int64 `json:"recharge_boost_window_ms"`
RechargeBoostFactorPercent float64 `json:"recharge_boost_factor_percent"`
JackpotMultipliers []float64 `json:"jackpot_multipliers"`
JackpotGlobalRTPMaxPercent float64 `json:"jackpot_global_rtp_max_percent"`
JackpotUserDayRTPMaxPercent float64 `json:"jackpot_user_day_rtp_max_percent"`
JackpotUser72hRTPMaxPercent float64 `json:"jackpot_user_72h_rtp_max_percent"`
JackpotSpendThresholdCoins int64 `json:"jackpot_spend_threshold_coins"`
MaxJackpotHitsPerUserDay int64 `json:"max_jackpot_hits_per_user_day"`
MaxSinglePayout int64 `json:"max_single_payout"`
UserHourlyPayoutCap int64 `json:"user_hourly_payout_cap"`
UserDailyPayoutCap int64 `json:"user_daily_payout_cap"`
DeviceDailyPayoutCap int64 `json:"device_daily_payout_cap"`
RoomHourlyPayoutCap int64 `json:"room_hourly_payout_cap"`
AnchorDailyPayoutCap int64 `json:"anchor_daily_payout_cap"`
Stages []stageDTO `json:"stages"` Stages []stageDTO `json:"stages"`
} }
@ -53,6 +78,8 @@ type configDTO struct {
type stageDTO struct { type stageDTO struct {
Stage string `json:"stage"` Stage string `json:"stage"`
MinRecharge7DCoins int64 `json:"min_recharge_7d_coins"`
MinRecharge30DCoins int64 `json:"min_recharge_30d_coins"`
Tiers []tierDTO `json:"tiers"` Tiers []tierDTO `json:"tiers"`
} }
@ -105,15 +132,44 @@ type drawSummaryDTO struct {
type poolBalanceDTO struct { type poolBalanceDTO struct {
AppCode string `json:"app_code"` AppCode string `json:"app_code"`
PoolID string `json:"pool_id"` PoolID string `json:"pool_id"`
StrategyVersion string `json:"strategy_version"`
Balance int64 `json:"balance"` Balance int64 `json:"balance"`
ReserveFloor int64 `json:"reserve_floor"` ReserveFloor int64 `json:"reserve_floor"`
AvailableBalance int64 `json:"available_balance"` AvailableBalance int64 `json:"available_balance"`
TotalIn int64 `json:"total_in"` TotalIn int64 `json:"total_in"`
TotalOut int64 `json:"total_out"` TotalOut int64 `json:"total_out"`
ManualCreditTotal int64 `json:"manual_credit_total"`
ManualDebitTotal int64 `json:"manual_debit_total"`
Materialized bool `json:"materialized"` Materialized bool `json:"materialized"`
UpdatedAtMS int64 `json:"updated_at_ms"` 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) { func (h *Handler) GetLuckyGiftConfig(c *gin.Context) {
if strings.TrimSpace(c.Query("app_code")) == "" { if strings.TrimSpace(c.Query("app_code")) == "" {
response.BadRequest(c, "app_code is required") response.BadRequest(c, "app_code is required")
@ -250,6 +306,7 @@ func (h *Handler) ListLuckyGiftPoolBalances(c *gin.Context) {
resp, err := h.luckyGift.ListLuckyGiftPoolBalances(ctx, &luckygiftv1.ListLuckyGiftPoolBalancesRequest{ resp, err := h.luckyGift.ListLuckyGiftPoolBalances(ctx, &luckygiftv1.ListLuckyGiftPoolBalancesRequest{
Meta: h.meta(c), Meta: h.meta(c),
PoolId: strings.TrimSpace(c.Query("pool_id")), PoolId: strings.TrimSpace(c.Query("pool_id")),
StrategyVersion: strings.ToLower(strings.TrimSpace(c.Query("strategy_version"))),
}) })
if err != nil { if err != nil {
response.ServerError(c, "获取幸运礼物奖池余额失败") response.ServerError(c, "获取幸运礼物奖池余额失败")
@ -262,6 +319,75 @@ func (h *Handler) ListLuckyGiftPoolBalances(c *gin.Context) {
response.OK(c, gin.H{"items": items, "total": len(items)}) 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 { func (h *Handler) meta(c *gin.Context) *luckygiftv1.RequestMeta {
return h.metaForApp(c, "") return h.metaForApp(c, "")
} }
@ -286,6 +412,12 @@ func (h *Handler) luckyGiftContext(c *gin.Context) (context.Context, context.Can
} }
func configToProto(req configRequest) *luckygiftv1.LuckyGiftRuleConfig { 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)) stages := make([]*luckygiftv1.LuckyGiftRuleStage, 0, len(req.Stages))
for _, stage := range req.Stages { for _, stage := range req.Stages {
stageName := strings.TrimSpace(stage.Stage) stageName := strings.TrimSpace(stage.Stage)
@ -306,21 +438,46 @@ func configToProto(req configRequest) *luckygiftv1.LuckyGiftRuleConfig {
} }
stages = append(stages, &luckygiftv1.LuckyGiftRuleStage{ stages = append(stages, &luckygiftv1.LuckyGiftRuleStage{
Stage: stageName, Stage: stageName,
MinRecharge_7DCoins: stage.MinRecharge7DCoins,
MinRecharge_30DCoins: stage.MinRecharge30DCoins,
Tiers: tiers, Tiers: tiers,
}) })
} }
return &luckygiftv1.LuckyGiftRuleConfig{ return &luckygiftv1.LuckyGiftRuleConfig{
AppCode: strings.ToLower(strings.TrimSpace(req.AppCode)), AppCode: strings.ToLower(strings.TrimSpace(req.AppCode)),
PoolId: strings.TrimSpace(req.PoolID), PoolId: strings.TrimSpace(req.PoolID),
StrategyVersion: strategyVersion,
Enabled: req.Enabled, Enabled: req.Enabled,
TargetRtpPpm: percentToPPM(req.TargetRTPPercent), TargetRtpPpm: percentToPPM(req.TargetRTPPercent),
PoolRatePpm: percentToPPM(req.PoolRatePercent), PoolRatePpm: percentToPPM(req.PoolRatePercent),
ProfitRatePpm: percentToPPM(req.ProfitRatePercent),
AnchorRatePpm: percentToPPM(req.AnchorRatePercent),
SettlementWindowWager: req.SettlementWindowWager, SettlementWindowWager: req.SettlementWindowWager,
ControlBandPpm: percentToPPM(req.ControlBandPercent), ControlBandPpm: percentToPPM(req.ControlBandPercent),
GiftPriceReference: req.GiftPriceReference, GiftPriceReference: req.GiftPriceReference,
NoviceMaxEquivalentDraws: req.NoviceMaxEquivalentDraws, NoviceMaxEquivalentDraws: req.NoviceMaxEquivalentDraws,
NormalMaxEquivalentDraws: req.NormalMaxEquivalentDraws, NormalMaxEquivalentDraws: req.NormalMaxEquivalentDraws,
EffectiveFromMs: req.EffectiveFromMS, EffectiveFromMs: req.EffectiveFromMS,
InitialPoolCoins: initialPoolCoins,
LossStreakGuarantee: req.LossStreakGuarantee,
LowWatermarkCoins: req.LowWatermarkCoins,
LowWaterNonzeroFactorPpm: percentToPPM(req.LowWaterNonzeroFactorPercent),
HighWatermarkCoins: req.HighWatermarkCoins,
HighWaterNonzeroFactorPpm: percentToPPM(req.HighWaterNonzeroFactorPercent),
RechargeBoostWindowMs: req.RechargeBoostWindowMS,
RechargeBoostFactorPpm: percentToPPM(req.RechargeBoostFactorPercent),
JackpotMultiplierPpms: multipliersToPPM(req.JackpotMultipliers),
JackpotGlobalRtpMaxPpm: percentToPPM(req.JackpotGlobalRTPMaxPercent),
JackpotUserDayRtpMaxPpm: percentToPPM(req.JackpotUserDayRTPMaxPercent),
JackpotUser_72HRtpMaxPpm: percentToPPM(req.JackpotUser72hRTPMaxPercent),
JackpotSpendThresholdCoins: req.JackpotSpendThresholdCoins,
MaxJackpotHitsPerUserDay: req.MaxJackpotHitsPerUserDay,
MaxSinglePayout: req.MaxSinglePayout,
UserHourlyPayoutCap: req.UserHourlyPayoutCap,
UserDailyPayoutCap: req.UserDailyPayoutCap,
DeviceDailyPayoutCap: req.DeviceDailyPayoutCap,
RoomHourlyPayoutCap: req.RoomHourlyPayoutCap,
AnchorDailyPayoutCap: req.AnchorDailyPayoutCap,
Stages: stages, Stages: stages,
} }
} }
@ -343,10 +500,18 @@ func configFromProto(config *luckygiftv1.LuckyGiftRuleConfig) configDTO {
} }
stages = append(stages, stageDTO{ stages = append(stages, stageDTO{
Stage: stage.GetStage(), Stage: stage.GetStage(),
MinRecharge7DCoins: stage.GetMinRecharge_7DCoins(),
MinRecharge30DCoins: stage.GetMinRecharge_30DCoins(),
Tiers: tiers, Tiers: tiers,
}) })
} }
poolID := strings.TrimSpace(config.GetPoolId()) 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{ return configDTO{
RuleVersion: config.GetRuleVersion(), RuleVersion: config.GetRuleVersion(),
CreatedByAdminID: config.GetCreatedByAdminId(), CreatedByAdminID: config.GetCreatedByAdminId(),
@ -354,15 +519,38 @@ func configFromProto(config *luckygiftv1.LuckyGiftRuleConfig) configDTO {
configRequest: configRequest{ configRequest: configRequest{
AppCode: config.GetAppCode(), AppCode: config.GetAppCode(),
PoolID: poolID, PoolID: poolID,
StrategyVersion: strategyVersion,
Enabled: config.GetEnabled(), Enabled: config.GetEnabled(),
TargetRTPPercent: ppmToPercent(config.GetTargetRtpPpm()), TargetRTPPercent: ppmToPercent(config.GetTargetRtpPpm()),
PoolRatePercent: ppmToPercent(config.GetPoolRatePpm()), PoolRatePercent: ppmToPercent(config.GetPoolRatePpm()),
ProfitRatePercent: ppmToPercent(config.GetProfitRatePpm()),
AnchorRatePercent: ppmToPercent(config.GetAnchorRatePpm()),
SettlementWindowWager: config.GetSettlementWindowWager(), SettlementWindowWager: config.GetSettlementWindowWager(),
ControlBandPercent: ppmToPercent(config.GetControlBandPpm()), ControlBandPercent: ppmToPercent(config.GetControlBandPpm()),
GiftPriceReference: config.GetGiftPriceReference(), GiftPriceReference: config.GetGiftPriceReference(),
NoviceMaxEquivalentDraws: config.GetNoviceMaxEquivalentDraws(), NoviceMaxEquivalentDraws: config.GetNoviceMaxEquivalentDraws(),
NormalMaxEquivalentDraws: config.GetNormalMaxEquivalentDraws(), NormalMaxEquivalentDraws: config.GetNormalMaxEquivalentDraws(),
EffectiveFromMS: config.GetEffectiveFromMs(), EffectiveFromMS: config.GetEffectiveFromMs(),
InitialPoolCoins: initialPoolCoins,
LossStreakGuarantee: config.GetLossStreakGuarantee(),
LowWatermarkCoins: config.GetLowWatermarkCoins(),
LowWaterNonzeroFactorPercent: ppmToPercent(config.GetLowWaterNonzeroFactorPpm()),
HighWatermarkCoins: config.GetHighWatermarkCoins(),
HighWaterNonzeroFactorPercent: ppmToPercent(config.GetHighWaterNonzeroFactorPpm()),
RechargeBoostWindowMS: config.GetRechargeBoostWindowMs(),
RechargeBoostFactorPercent: ppmToPercent(config.GetRechargeBoostFactorPpm()),
JackpotMultipliers: ppmsToMultipliers(config.GetJackpotMultiplierPpms()),
JackpotGlobalRTPMaxPercent: ppmToPercent(config.GetJackpotGlobalRtpMaxPpm()),
JackpotUserDayRTPMaxPercent: ppmToPercent(config.GetJackpotUserDayRtpMaxPpm()),
JackpotUser72hRTPMaxPercent: ppmToPercent(config.GetJackpotUser_72HRtpMaxPpm()),
JackpotSpendThresholdCoins: config.GetJackpotSpendThresholdCoins(),
MaxJackpotHitsPerUserDay: config.GetMaxJackpotHitsPerUserDay(),
MaxSinglePayout: config.GetMaxSinglePayout(),
UserHourlyPayoutCap: config.GetUserHourlyPayoutCap(),
UserDailyPayoutCap: config.GetUserDailyPayoutCap(),
DeviceDailyPayoutCap: config.GetDeviceDailyPayoutCap(),
RoomHourlyPayoutCap: config.GetRoomHourlyPayoutCap(),
AnchorDailyPayoutCap: config.GetAnchorDailyPayoutCap(),
Stages: stages, Stages: stages,
}, },
} }
@ -403,10 +591,26 @@ func multiplierToPPM(value float64) int64 {
return int64(math.Round(value * 1_000_000)) return int64(math.Round(value * 1_000_000))
} }
func multipliersToPPM(values []float64) []int64 {
out := make([]int64, 0, len(values))
for _, value := range values {
out = append(out, multiplierToPPM(value))
}
return out
}
func ppmToMultiplier(value int64) float64 { func ppmToMultiplier(value int64) float64 {
return float64(value) / 1_000_000 return float64(value) / 1_000_000
} }
func ppmsToMultipliers(values []int64) []float64 {
out := make([]float64, 0, len(values))
for _, value := range values {
out = append(out, ppmToMultiplier(value))
}
return out
}
func drawFromProto(draw *luckygiftv1.LuckyGiftDrawResult) drawDTO { func drawFromProto(draw *luckygiftv1.LuckyGiftDrawResult) drawDTO {
if draw == nil { if draw == nil {
return drawDTO{} return drawDTO{}
@ -462,16 +666,38 @@ func poolBalanceFromProto(pool *luckygiftv1.LuckyGiftPoolBalance) poolBalanceDTO
return poolBalanceDTO{ return poolBalanceDTO{
AppCode: pool.GetAppCode(), AppCode: pool.GetAppCode(),
PoolID: pool.GetPoolId(), PoolID: pool.GetPoolId(),
StrategyVersion: pool.GetStrategyVersion(),
Balance: pool.GetBalance(), Balance: pool.GetBalance(),
ReserveFloor: pool.GetReserveFloor(), ReserveFloor: pool.GetReserveFloor(),
AvailableBalance: pool.GetAvailableBalance(), AvailableBalance: pool.GetAvailableBalance(),
TotalIn: pool.GetTotalIn(), TotalIn: pool.GetTotalIn(),
TotalOut: pool.GetTotalOut(), TotalOut: pool.GetTotalOut(),
ManualCreditTotal: pool.GetManualCreditTotal(),
ManualDebitTotal: pool.GetManualDebitTotal(),
Materialized: pool.GetMaterialized(), Materialized: pool.GetMaterialized(),
UpdatedAtMS: pool.GetUpdatedAtMs(), 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 { func parseOptionalInt64(value string) int64 {
value = strings.TrimSpace(value) value = strings.TrimSpace(value)
if value == "" { if value == "" {

View File

@ -1,11 +1,35 @@
package luckygift package luckygift
import ( import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing" "testing"
"time"
"hyapp-admin-server/internal/integration/luckygiftadmin"
"hyapp-admin-server/internal/middleware"
luckygiftv1 "hyapp.local/api/proto/luckygift/v1" 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) { func TestConfigToProtoConvertsBusinessUnitsToPPM(t *testing.T) {
config := configToProto(configRequest{ config := configToProto(configRequest{
PoolID: " lucky_100 ", 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) { func TestConfigToProtoGeneratesUniqueTierIDs(t *testing.T) {
config := configToProto(configRequest{ config := configToProto(configRequest{
PoolID: "lucky", PoolID: "lucky",
@ -137,3 +218,79 @@ func TestConfigFromProtoConvertsPPMToBusinessUnits(t *testing.T) {
t.Fatalf("probability percent = %v, want 22", config.Stages[0].Tiers[0].ProbabilityPercent) t.Fatalf("probability percent = %v, want 22", config.Stages[0].Tiers[0].ProbabilityPercent)
} }
} }
func TestDynamicConfigAdminMappingRoundTrip(t *testing.T) {
protoConfig := configToProto(configRequest{
AppCode: " LALU ",
PoolID: " lucky ",
StrategyVersion: " DYNAMIC_V3 ",
Enabled: true,
TargetRTPPercent: 98,
PoolRatePercent: 98,
ProfitRatePercent: 1,
AnchorRatePercent: 1,
SettlementWindowWager: 1_000_000,
ControlBandPercent: 3,
GiftPriceReference: 100,
InitialPoolCoins: 1_000_000,
LossStreakGuarantee: 5,
LowWatermarkCoins: 10_000_000,
LowWaterNonzeroFactorPercent: 70,
HighWatermarkCoins: 20_000_000,
HighWaterNonzeroFactorPercent: 130,
RechargeBoostWindowMS: 300_000,
RechargeBoostFactorPercent: 110,
JackpotMultipliers: []float64{200, 500, 1000},
JackpotGlobalRTPMaxPercent: 98,
JackpotUserDayRTPMaxPercent: 96,
JackpotUser72hRTPMaxPercent: 96,
JackpotSpendThresholdCoins: 5_000,
MaxJackpotHitsPerUserDay: 5,
MaxSinglePayout: 10_000,
UserHourlyPayoutCap: 20_000,
UserDailyPayoutCap: 30_000,
DeviceDailyPayoutCap: 40_000,
RoomHourlyPayoutCap: 50_000,
AnchorDailyPayoutCap: 60_000,
Stages: []stageDTO{{
Stage: " normal ",
MinRecharge7DCoins: 7_000,
MinRecharge30DCoins: 30_000,
Tiers: []tierDTO{{Multiplier: 1, ProbabilityPercent: 100, Enabled: true}},
}},
})
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)
}
if got := protoConfig.GetJackpotMultiplierPpms(); len(got) != 3 || got[0] != 200_000_000 || got[2] != 1_000_000_000 {
t.Fatalf("jackpot multiplier ppms=%v, want 200x/500x/1000x", got)
}
stage := protoConfig.GetStages()[0]
if stage.GetMinRecharge_7DCoins() != 7_000 || stage.GetMinRecharge_30DCoins() != 30_000 {
t.Fatalf("stage recharge thresholds were not mapped: %+v", stage)
}
dto := configFromProto(protoConfig)
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)
}
if len(dto.JackpotMultipliers) != 3 || dto.JackpotMultipliers[1] != 500 || dto.JackpotUser72hRTPMaxPercent != 96 {
t.Fatalf("jackpot DTO mismatch: %+v", dto)
}
if dto.MaxSinglePayout != 10_000 || dto.AnchorDailyPayoutCap != 60_000 || dto.Stages[0].MinRecharge30DCoins != 30_000 {
t.Fatalf("risk/stage 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/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/summary", middleware.RequirePermission("lucky-gift:view"), h.luckyGift.GetLuckyGiftDrawSummary)
group.GET("/lucky-gifts/pools", middleware.RequirePermission("lucky-gift:view"), h.luckyGift.ListLuckyGiftPoolBalances) 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", "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", "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", "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{ activityManage = []string{
"activity-template:create", "activity-template:update", "activity-template:publish", "activity-template:delete", "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", "wheel:update", "weekly-star:create", "weekly-star:update", "weekly-star:settle",
"agency-opening:create", "agency-opening:update", "red-packet:update", "agency-opening:create", "agency-opening:update", "red-packet:update",
"cp-config:update", "cp-weekly-rank:update", "vip-config: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{ gameRead = []string{
"game-catalog:view", "self-game:view", "game-robot:view", "room-rps-config:view", "room-rps-order:view", "game-catalog:view", "self-game:view", "game-robot:view", "room-rps-config:view", "room-rps-order:view",
@ -178,7 +180,7 @@ func defaultRolePermissionCodes(code string) []string {
roomRead, roomManage, roomRead, roomManage,
[]string{"app-config:view"}, appConfigManage, []string{"app-config:view"}, appConfigManage,
resourceRead, resourceManage, resourceRead, resourceManage,
operationsRead, operationsLeadManage, operationsRead, operationsLeadManage, luckyGiftPoolManage,
externalAdminUserOperate, externalAdminUserOperate,
paymentRead, paymentBillExport, paymentBillRefresh, paymentOperationsManage, paymentRead, paymentBillExport, paymentBillRefresh, paymentOperationsManage,
activityRead, activityManage, activityRead, activityManage,

View File

@ -204,6 +204,8 @@ var defaultPermissions = []model.Permission{
{Name: "七日签到更新", Code: "seven-day-checkin:update", Kind: "button"}, {Name: "七日签到更新", Code: "seven-day-checkin:update", Kind: "button"},
{Name: "幸运礼物查看", Code: "lucky-gift:view", Kind: "menu"}, {Name: "幸运礼物查看", Code: "lucky-gift:view", Kind: "menu"},
{Name: "幸运礼物更新", Code: "lucky-gift:update", Kind: "button"}, {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:view", Kind: "menu"},
{Name: "转盘抽奖更新", Code: "wheel:update", Kind: "button"}, {Name: "转盘抽奖更新", Code: "wheel:update", Kind: "button"},
{Name: "房间火箭查看", Code: "room-rocket:view", Kind: "menu"}, {Name: "房间火箭查看", Code: "room-rocket:view", Kind: "menu"},

View File

@ -160,6 +160,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) { func TestRolePermissionMigrationMatchesManagedMatrix(t *testing.T) {
content, err := os.ReadFile("../../migrations/093_role_permission_matrix.sql") content, err := os.ReadFile("../../migrations/093_role_permission_matrix.sql")
if err != nil { if err != nil {
@ -327,6 +342,22 @@ func TestExternalAdminPermissionAssignmentMigrationIsIncrementalAndPlatformOnly(
} }
} }
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) { func assertMigrationPermissionCodes(t *testing.T, roleCode string, sqlList string) {
t.Helper() t.Helper()
quotedCode := regexp.MustCompile(`'([a-z0-9:-]+)'`) quotedCode := regexp.MustCompile(`'([a-z0-9:-]+)'`)
@ -343,6 +374,7 @@ func assertMigrationPermissionCodes(t *testing.T, roleCode string, sqlList strin
"activity-template:view", "activity-template:create", "activity-template:update", "activity-template:publish", "activity-template:view", "activity-template:create", "activity-template:update", "activity-template:publish",
"activity-template:delete", "activity-template:data", "activity-template:export", "activity-template:retry", "activity-template:delete", "activity-template:data", "activity-template:export", "activity-template:retry",
"external-admin-user:view", "external-admin-user:create", "external-admin-user:status", "external-admin-user:reset-password", "external-admin-user:view", "external-admin-user:create", "external-admin-user:status", "external-admin-user:reset-password",
"lucky-gift:view", "lucky-gift:update", "lucky-gift:pool-credit", "lucky-gift:pool-debit",
}) })
if roleCode == roleCodeOperationsLead || roleCode == roleCodeOperationsSpecialist { if roleCode == roleCodeOperationsLead || roleCode == roleCodeOperationsSpecialist {
// 093 仍记录旧的财务提现权限102 才把固定运营岗位切换到独立运营审核权限。 // 093 仍记录旧的财务提现权限102 才把固定运营岗位切换到独立运营审核权限。

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

@ -14,6 +14,7 @@ type SendCommand struct {
AppCode string AppCode string
RequestID string RequestID string
ExternalUserID string ExternalUserID string
DeviceID string
GiftCount int64 GiftCount int64
UnitAmount int64 UnitAmount int64
TotalAmount int64 TotalAmount int64
@ -59,6 +60,7 @@ func (c *GRPCClient) SendLuckyGift(ctx context.Context, cmd SendCommand) (SendRe
}, },
AppCode: cmd.AppCode, AppCode: cmd.AppCode,
ExternalUserId: cmd.ExternalUserID, ExternalUserId: cmd.ExternalUserID,
DeviceId: cmd.DeviceID,
RequestId: cmd.RequestID, RequestId: cmd.RequestID,
GiftCount: cmd.GiftCount, GiftCount: cmd.GiftCount,
UnitAmount: cmd.UnitAmount, UnitAmount: cmd.UnitAmount,

View File

@ -17,20 +17,25 @@ type Handler struct {
client luckygiftclient.Client client luckygiftclient.Client
allowedApps map[string]struct{} allowedApps map[string]struct{}
requestTimeout time.Duration requestTimeout time.Duration
now func() time.Time
} }
func New(client luckygiftclient.Client, requestTimeout time.Duration, allowedApps map[string]struct{}) *Handler { func New(client luckygiftclient.Client, requestTimeout time.Duration, allowedApps map[string]struct{}) *Handler {
if requestTimeout <= 0 { if requestTimeout <= 0 {
requestTimeout = 3 * time.Second requestTimeout = 3 * time.Second
} }
return &Handler{client: client, allowedApps: cloneAllowedApps(allowedApps), requestTimeout: requestTimeout, now: time.Now} return &Handler{client: client, allowedApps: cloneAllowedApps(allowedApps), requestTimeout: requestTimeout}
} }
type sendRequest struct { type sendRequest struct {
AppCode string `json:"app_code"` AppCode string `json:"app_code"`
RequestID string `json:"request_id"` RequestID string `json:"request_id"`
ExternalUserID string `json:"external_user_id"` ExternalUserID string `json:"external_user_id"`
// DeviceID 由外部 App 在自己的认证/请求签名边界内绑定。
// luck-gateway 目前只做 App allowlist不能独立证明该值来自真实设备dynamic_v3 由 owner 对缺失值 fail-close。
DeviceID string `json:"device_id"`
// PaidAtMS 是外部 App 自己完成扣费的 UTC epoch ms调用方必须把它纳入自己的认证/签名边界。
// gateway 不用到达时间替代。fixed_v2 允许 0 兼容dynamic_v3 由 owner 读取规则后强制要求正值。
PaidAtMS int64 `json:"paid_at_ms"`
GiftCount int64 `json:"gift_count"` GiftCount int64 `json:"gift_count"`
UnitAmount int64 `json:"unit_amount"` UnitAmount int64 `json:"unit_amount"`
Currency string `json:"currency"` Currency string `json:"currency"`
@ -63,14 +68,14 @@ func (h *Handler) Send(w http.ResponseWriter, r *http.Request) {
AppCode: appCode, AppCode: appCode,
RequestID: strings.TrimSpace(req.RequestID), RequestID: strings.TrimSpace(req.RequestID),
ExternalUserID: strings.TrimSpace(req.ExternalUserID), ExternalUserID: strings.TrimSpace(req.ExternalUserID),
DeviceID: strings.TrimSpace(req.DeviceID),
GiftCount: req.GiftCount, GiftCount: req.GiftCount,
UnitAmount: req.UnitAmount, UnitAmount: req.UnitAmount,
TotalAmount: req.GiftCount * req.UnitAmount, TotalAmount: req.GiftCount * req.UnitAmount,
Currency: strings.TrimSpace(req.Currency), Currency: strings.TrimSpace(req.Currency),
MetadataJSON: metadataJSON(req.Metadata), MetadataJSON: metadataJSON(req.Metadata),
// 外部 HTTP 契约不暴露 pool_id/paid_at_mspool 由 owner service 默认规则选择,支付完成时间使用网关服务端 UTC 时间, // pool 仍由 owner 默认规则选择paid_at_ms 则必须保持外部账务事实,不能改写成 gateway 收包时间。
// 避免外部 App 通过请求体影响奖池分桶、预算日或风控窗口。 PaidAtMS: req.PaidAtMS,
PaidAtMS: normalizePaidAt(h.now),
}) })
if err != nil { if err != nil {
response.Fail(w, http.StatusInternalServerError, response.CodeServerError, requestID, "send lucky gift failed") response.Fail(w, http.StatusInternalServerError, response.CodeServerError, requestID, "send lucky gift failed")
@ -98,9 +103,20 @@ func (r sendRequest) validate() error {
if len(strings.TrimSpace(r.ExternalUserID)) > 128 { if len(strings.TrimSpace(r.ExternalUserID)) > 128 {
return errors.New("external_user_id is too long") return errors.New("external_user_id is too long")
} }
if len(strings.TrimSpace(r.DeviceID)) > 128 {
return errors.New("device_id is too long")
}
if r.PaidAtMS < 0 {
return errors.New("paid_at_ms is invalid")
}
// 不在这里统一要求 device_id网关无法获知本次命中的不可变规则是 fixed_v2 还是 dynamic_v3。
// owner 读到规则后对 dynamic_v3 权威拒绝空值fixed_v2 继续兼容旧调用方。
if r.GiftCount <= 0 || r.UnitAmount <= 0 { if r.GiftCount <= 0 || r.UnitAmount <= 0 {
return errors.New("gift_count and unit_amount must be positive") return errors.New("gift_count and unit_amount must be positive")
} }
if r.GiftCount > 999 {
return errors.New("gift_count cannot exceed 999")
}
// 网关只接受数量和单价total_amount 由服务端计算,避免外部 App SDK 或恶意调用方传入不一致账面金额。 // 网关只接受数量和单价total_amount 由服务端计算,避免外部 App SDK 或恶意调用方传入不一致账面金额。
if r.GiftCount > (1<<63-1)/r.UnitAmount { if r.GiftCount > (1<<63-1)/r.UnitAmount {
return errors.New("gift amount is too large") return errors.New("gift amount is too large")
@ -127,10 +143,6 @@ func metadataJSON(value map[string]any) string {
return string(body) return string(body)
} }
func normalizePaidAt(now func() time.Time) int64 {
return now().UTC().UnixMilli()
}
func (h *Handler) isAllowedApp(appCode string) bool { func (h *Handler) isAllowedApp(appCode string) bool {
if len(h.allowedApps) == 0 { if len(h.allowedApps) == 0 {
return false return false

View File

@ -30,7 +30,7 @@ func (f *fakeClient) SendLuckyGift(_ context.Context, cmd luckygiftclient.SendCo
func TestSendAcceptsAllowedAppAndComputesAmount(t *testing.T) { func TestSendAcceptsAllowedAppAndComputesAmount(t *testing.T) {
client := &fakeClient{} client := &fakeClient{}
handler := testRouter(client, "aslan,yumi") handler := testRouter(client, "aslan,yumi")
body := []byte(`{"app_code":"Aslan","request_id":"req-1","external_user_id":"u-1001","gift_count":3,"unit_amount":7,"currency":"COIN","metadata":{"gift":"rose"}}`) body := []byte(`{"app_code":"Aslan","request_id":"req-1","external_user_id":"u-1001","device_id":"device-attested-by-aslan","paid_at_ms":1700000000000,"gift_count":3,"unit_amount":7,"currency":"COIN","metadata":{"gift":"rose"}}`)
req := httptest.NewRequest(http.MethodPost, "/api/v1/lucky-gifts/send", bytes.NewReader(body)) req := httptest.NewRequest(http.MethodPost, "/api/v1/lucky-gifts/send", bytes.NewReader(body))
rec := httptest.NewRecorder() rec := httptest.NewRecorder()
@ -41,12 +41,27 @@ func TestSendAcceptsAllowedAppAndComputesAmount(t *testing.T) {
if client.calls != 1 { if client.calls != 1 {
t.Fatalf("client calls=%d", client.calls) t.Fatalf("client calls=%d", client.calls)
} }
if client.got.AppCode != "aslan" || client.got.RequestID != "req-1" || client.got.TotalAmount != 21 || client.got.GiftCount != 3 { if client.got.AppCode != "aslan" || client.got.RequestID != "req-1" || client.got.DeviceID != "device-attested-by-aslan" || client.got.PaidAtMS != 1700000000000 || client.got.TotalAmount != 21 || client.got.GiftCount != 3 {
t.Fatalf("unexpected command: %+v", client.got) t.Fatalf("unexpected command: %+v", client.got)
} }
} }
func TestSendRejectsPoolAndPaidAtOverrides(t *testing.T) { func TestSendKeepsMissingDeviceCompatibleForFixedV2OwnerDecision(t *testing.T) {
client := &fakeClient{}
handler := testRouter(client, "aslan")
// luck-gateway 不读规则版本,因此旧 fixed_v2 调用方可继续缺省 device_id
// 如果本 App 已发布 dynamic_v3lucky-gift owner 会在规则事务内明确拒绝。
body := []byte(`{"app_code":"aslan","request_id":"req-fixed-legacy","external_user_id":"u-legacy","gift_count":1,"unit_amount":7}`)
req := httptest.NewRequest(http.MethodPost, "/api/v1/lucky-gifts/send", bytes.NewReader(body))
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusOK || client.calls != 1 || client.got.DeviceID != "" {
t.Fatalf("fixed-compatible request status=%d calls=%d command=%+v body=%s", rec.Code, client.calls, client.got, rec.Body.String())
}
}
func TestSendRejectsPoolOverride(t *testing.T) {
client := &fakeClient{} client := &fakeClient{}
handler := testRouter(client, "aslan") handler := testRouter(client, "aslan")
body := []byte(`{"app_code":"aslan","request_id":"req-1","external_user_id":"u-1001","pool_id":"pool-1","paid_at_ms":1700000000000,"gift_count":1,"unit_amount":7}`) body := []byte(`{"app_code":"aslan","request_id":"req-1","external_user_id":"u-1001","pool_id":"pool-1","paid_at_ms":1700000000000,"gift_count":1,"unit_amount":7}`)
@ -62,6 +77,19 @@ func TestSendRejectsPoolAndPaidAtOverrides(t *testing.T) {
} }
} }
func TestSendRejectsNegativePaidAt(t *testing.T) {
client := &fakeClient{}
handler := testRouter(client, "aslan")
body := []byte(`{"app_code":"aslan","request_id":"req-negative-paid-at","external_user_id":"u-1001","paid_at_ms":-1,"gift_count":1,"unit_amount":7}`)
req := httptest.NewRequest(http.MethodPost, "/api/v1/lucky-gifts/send", bytes.NewReader(body))
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusBadRequest || client.calls != 0 {
t.Fatalf("negative paid_at_ms status=%d calls=%d body=%s", rec.Code, client.calls, rec.Body.String())
}
}
func TestSendRejectsDisallowedAppBeforeClientCall(t *testing.T) { func TestSendRejectsDisallowedAppBeforeClientCall(t *testing.T) {
client := &fakeClient{} client := &fakeClient{}
handler := testRouter(client, "aslan,yumi") handler := testRouter(client, "aslan,yumi")
@ -100,6 +128,19 @@ func TestSendRejectsInvalidAmount(t *testing.T) {
} }
} }
func TestSendRejectsMoreThan999SequentialDraws(t *testing.T) {
client := &fakeClient{}
handler := testRouter(client, "aslan")
body := []byte(`{"app_code":"aslan","request_id":"req-1","external_user_id":"u-1","gift_count":1000,"unit_amount":1}`)
req := httptest.NewRequest(http.MethodPost, "/api/v1/lucky-gifts/send", bytes.NewReader(body))
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusBadRequest || client.calls != 0 {
t.Fatalf("oversized sequential draw status=%d calls=%d body=%s", rec.Code, client.calls, rec.Body.String())
}
}
func TestSendRejectsTooLongExternalFields(t *testing.T) { func TestSendRejectsTooLongExternalFields(t *testing.T) {
client := &fakeClient{} client := &fakeClient{}
handler := testRouter(client, "aslan") handler := testRouter(client, "aslan")

View File

@ -21,84 +21,72 @@ tasks:
timeout: "3s" timeout: "3s"
lock_ttl: "30s" lock_ttl: "30s"
batch_size: 100 batch_size: 100
app_codes: ["lalu","fami","huwaa"]
user_region_rebuild: user_region_rebuild:
enabled: true enabled: true
interval: "5s" interval: "5s"
timeout: "5s" timeout: "5s"
lock_ttl: "30s" lock_ttl: "30s"
batch_size: 500 batch_size: 500
app_codes: ["lalu","fami","huwaa"]
message_fanout: message_fanout:
enabled: true enabled: true
interval: "1s" interval: "1s"
timeout: "10s" timeout: "10s"
lock_ttl: "30s" lock_ttl: "30s"
batch_size: 500 batch_size: 500
app_codes: ["lalu","fami","huwaa"]
vip_daily_coin_rebate: vip_daily_coin_rebate:
enabled: true enabled: true
interval: "1m" interval: "1m"
timeout: "30s" timeout: "30s"
lock_ttl: "1m" lock_ttl: "1m"
batch_size: 500 batch_size: 500
app_codes: ["lalu","fami","huwaa"]
growth_level_reward: growth_level_reward:
enabled: true enabled: true
interval: "5s" interval: "5s"
timeout: "10s" timeout: "10s"
lock_ttl: "30s" lock_ttl: "30s"
batch_size: 100 batch_size: 100
app_codes: ["lalu","fami","huwaa"]
temporary_growth_level: temporary_growth_level:
enabled: true enabled: true
interval: "5s" interval: "5s"
timeout: "15s" timeout: "15s"
lock_ttl: "30s" lock_ttl: "30s"
batch_size: 100 batch_size: 100
app_codes: ["lalu","fami","huwaa"]
weekly_star_settlement: weekly_star_settlement:
enabled: true enabled: true
interval: "5s" interval: "5s"
timeout: "20s" timeout: "20s"
lock_ttl: "1m" lock_ttl: "1m"
batch_size: 50 batch_size: 50
app_codes: ["lalu","fami","huwaa"]
activity_template_settlement: activity_template_settlement:
enabled: true enabled: true
interval: "5s" interval: "5s"
timeout: "30s" timeout: "30s"
lock_ttl: "1m" lock_ttl: "1m"
batch_size: 50 batch_size: 50
app_codes: ["lalu","fami","huwaa"]
agency_opening_settlement: agency_opening_settlement:
enabled: true enabled: true
interval: "5s" interval: "5s"
timeout: "20s" timeout: "20s"
lock_ttl: "1m" lock_ttl: "1m"
batch_size: 50 batch_size: 50
app_codes: ["lalu","fami","huwaa"]
room_turnover_reward_settlement: room_turnover_reward_settlement:
enabled: true enabled: true
interval: "5m" interval: "5m"
timeout: "30s" timeout: "30s"
lock_ttl: "5m" lock_ttl: "5m"
batch_size: 100 batch_size: 100
app_codes: ["lalu","fami","huwaa"]
cp_intimacy_leaderboard: cp_intimacy_leaderboard:
enabled: true enabled: true
interval: "5m" interval: "5m"
timeout: "20s" timeout: "20s"
lock_ttl: "5m" lock_ttl: "5m"
batch_size: 10000 batch_size: 10000
app_codes: ["lalu","fami","huwaa"]
game_level_event_relay: game_level_event_relay:
enabled: true enabled: true
interval: "5s" interval: "5s"
timeout: "10s" timeout: "10s"
lock_ttl: "30s" lock_ttl: "30s"
batch_size: 100 batch_size: 100
app_codes: ["lalu","fami","huwaa"]
mic_open_session_compensation: mic_open_session_compensation:
enabled: true enabled: true
interval: "60s" interval: "60s"
@ -107,7 +95,6 @@ tasks:
batch_size: 100 batch_size: 100
pending_publish_max_age: "2m" pending_publish_max_age: "2m"
publishing_session_max_age: "12h" publishing_session_max_age: "12h"
app_codes: ["lalu","fami","huwaa"]
room_open_session_compensation: room_open_session_compensation:
enabled: true enabled: true
interval: "60s" interval: "60s"
@ -115,18 +102,15 @@ tasks:
lock_ttl: "60s" lock_ttl: "60s"
batch_size: 200 batch_size: 200
open_session_max_age: "24h" open_session_max_age: "24h"
app_codes: ["lalu","fami","huwaa"]
manager_user_block_expiry: manager_user_block_expiry:
enabled: true enabled: true
interval: "30s" interval: "30s"
timeout: "10s" timeout: "10s"
lock_ttl: "30s" lock_ttl: "30s"
batch_size: 100 batch_size: 100
app_codes: ["lalu","fami","huwaa"]
admin_user_ban_expiry: admin_user_ban_expiry:
enabled: true enabled: true
interval: "30s" interval: "30s"
timeout: "10s" timeout: "10s"
lock_ttl: "30s" lock_ttl: "30s"
batch_size: 100 batch_size: 100
app_codes: ["lalu","fami","huwaa"]

View File

@ -21,84 +21,72 @@ tasks:
timeout: "3s" timeout: "3s"
lock_ttl: "30s" lock_ttl: "30s"
batch_size: 100 batch_size: 100
app_codes: ["lalu","fami","huwaa"]
user_region_rebuild: user_region_rebuild:
enabled: true enabled: true
interval: "5s" interval: "5s"
timeout: "5s" timeout: "5s"
lock_ttl: "30s" lock_ttl: "30s"
batch_size: 500 batch_size: 500
app_codes: ["lalu","fami","huwaa"]
message_fanout: message_fanout:
enabled: true enabled: true
interval: "1s" interval: "1s"
timeout: "10s" timeout: "10s"
lock_ttl: "30s" lock_ttl: "30s"
batch_size: 500 batch_size: 500
app_codes: ["lalu","fami","huwaa"]
vip_daily_coin_rebate: vip_daily_coin_rebate:
enabled: true enabled: true
interval: "1m" interval: "1m"
timeout: "30s" timeout: "30s"
lock_ttl: "1m" lock_ttl: "1m"
batch_size: 500 batch_size: 500
app_codes: ["lalu","fami","huwaa"]
growth_level_reward: growth_level_reward:
enabled: true enabled: true
interval: "5s" interval: "5s"
timeout: "10s" timeout: "10s"
lock_ttl: "30s" lock_ttl: "30s"
batch_size: 100 batch_size: 100
app_codes: ["lalu","fami","huwaa"]
temporary_growth_level: temporary_growth_level:
enabled: true enabled: true
interval: "5s" interval: "5s"
timeout: "15s" timeout: "15s"
lock_ttl: "30s" lock_ttl: "30s"
batch_size: 100 batch_size: 100
app_codes: ["lalu","fami","huwaa"]
weekly_star_settlement: weekly_star_settlement:
enabled: true enabled: true
interval: "5s" interval: "5s"
timeout: "20s" timeout: "20s"
lock_ttl: "1m" lock_ttl: "1m"
batch_size: 50 batch_size: 50
app_codes: ["lalu","fami","huwaa"]
activity_template_settlement: activity_template_settlement:
enabled: true enabled: true
interval: "5s" interval: "5s"
timeout: "30s" timeout: "30s"
lock_ttl: "1m" lock_ttl: "1m"
batch_size: 50 batch_size: 50
app_codes: ["lalu","fami","huwaa"]
agency_opening_settlement: agency_opening_settlement:
enabled: true enabled: true
interval: "5s" interval: "5s"
timeout: "20s" timeout: "20s"
lock_ttl: "1m" lock_ttl: "1m"
batch_size: 50 batch_size: 50
app_codes: ["lalu","fami","huwaa"]
room_turnover_reward_settlement: room_turnover_reward_settlement:
enabled: true enabled: true
interval: "5m" interval: "5m"
timeout: "30s" timeout: "30s"
lock_ttl: "5m" lock_ttl: "5m"
batch_size: 100 batch_size: 100
app_codes: ["lalu","fami","huwaa"]
cp_intimacy_leaderboard: cp_intimacy_leaderboard:
enabled: true enabled: true
interval: "5m" interval: "5m"
timeout: "20s" timeout: "20s"
lock_ttl: "5m" lock_ttl: "5m"
batch_size: 10000 batch_size: 10000
app_codes: ["lalu","fami","huwaa"]
game_level_event_relay: game_level_event_relay:
enabled: true enabled: true
interval: "5s" interval: "5s"
timeout: "10s" timeout: "10s"
lock_ttl: "30s" lock_ttl: "30s"
batch_size: 100 batch_size: 100
app_codes: ["lalu","fami","huwaa"]
mic_open_session_compensation: mic_open_session_compensation:
enabled: true enabled: true
interval: "60s" interval: "60s"
@ -107,7 +95,6 @@ tasks:
batch_size: 100 batch_size: 100
pending_publish_max_age: "2m" pending_publish_max_age: "2m"
publishing_session_max_age: "12h" publishing_session_max_age: "12h"
app_codes: ["lalu","fami","huwaa"]
room_open_session_compensation: room_open_session_compensation:
enabled: true enabled: true
interval: "60s" interval: "60s"
@ -115,18 +102,15 @@ tasks:
lock_ttl: "60s" lock_ttl: "60s"
batch_size: 200 batch_size: 200
open_session_max_age: "24h" open_session_max_age: "24h"
app_codes: ["lalu","fami","huwaa"]
manager_user_block_expiry: manager_user_block_expiry:
enabled: true enabled: true
interval: "30s" interval: "30s"
timeout: "10s" timeout: "10s"
lock_ttl: "30s" lock_ttl: "30s"
batch_size: 100 batch_size: 100
app_codes: ["lalu","fami","huwaa"]
admin_user_ban_expiry: admin_user_ban_expiry:
enabled: true enabled: true
interval: "30s" interval: "30s"
timeout: "10s" timeout: "10s"
lock_ttl: "30s" lock_ttl: "30s"
batch_size: 100 batch_size: 100
app_codes: ["lalu","fami","huwaa"]

View File

@ -21,84 +21,72 @@ tasks:
timeout: "3s" timeout: "3s"
lock_ttl: "30s" lock_ttl: "30s"
batch_size: 100 batch_size: 100
app_codes: ["lalu","fami","huwaa"]
user_region_rebuild: user_region_rebuild:
enabled: true enabled: true
interval: "5s" interval: "5s"
timeout: "5s" timeout: "5s"
lock_ttl: "30s" lock_ttl: "30s"
batch_size: 500 batch_size: 500
app_codes: ["lalu","fami","huwaa"]
message_fanout: message_fanout:
enabled: true enabled: true
interval: "1s" interval: "1s"
timeout: "10s" timeout: "10s"
lock_ttl: "30s" lock_ttl: "30s"
batch_size: 500 batch_size: 500
app_codes: ["lalu","fami","huwaa"]
vip_daily_coin_rebate: vip_daily_coin_rebate:
enabled: true enabled: true
interval: "1m" interval: "1m"
timeout: "30s" timeout: "30s"
lock_ttl: "1m" lock_ttl: "1m"
batch_size: 500 batch_size: 500
app_codes: ["lalu","fami","huwaa"]
growth_level_reward: growth_level_reward:
enabled: true enabled: true
interval: "5s" interval: "5s"
timeout: "10s" timeout: "10s"
lock_ttl: "30s" lock_ttl: "30s"
batch_size: 100 batch_size: 100
app_codes: ["lalu","fami","huwaa"]
temporary_growth_level: temporary_growth_level:
enabled: true enabled: true
interval: "5s" interval: "5s"
timeout: "15s" timeout: "15s"
lock_ttl: "30s" lock_ttl: "30s"
batch_size: 100 batch_size: 100
app_codes: ["lalu","fami","huwaa"]
weekly_star_settlement: weekly_star_settlement:
enabled: true enabled: true
interval: "5s" interval: "5s"
timeout: "20s" timeout: "20s"
lock_ttl: "1m" lock_ttl: "1m"
batch_size: 50 batch_size: 50
app_codes: ["lalu","fami","huwaa"]
activity_template_settlement: activity_template_settlement:
enabled: true enabled: true
interval: "5s" interval: "5s"
timeout: "30s" timeout: "30s"
lock_ttl: "1m" lock_ttl: "1m"
batch_size: 50 batch_size: 50
app_codes: ["lalu","fami","huwaa"]
agency_opening_settlement: agency_opening_settlement:
enabled: true enabled: true
interval: "5s" interval: "5s"
timeout: "20s" timeout: "20s"
lock_ttl: "1m" lock_ttl: "1m"
batch_size: 50 batch_size: 50
app_codes: ["lalu","fami","huwaa"]
room_turnover_reward_settlement: room_turnover_reward_settlement:
enabled: true enabled: true
interval: "5m" interval: "5m"
timeout: "30s" timeout: "30s"
lock_ttl: "5m" lock_ttl: "5m"
batch_size: 100 batch_size: 100
app_codes: ["lalu","fami","huwaa"]
cp_intimacy_leaderboard: cp_intimacy_leaderboard:
enabled: true enabled: true
interval: "5m" interval: "5m"
timeout: "20s" timeout: "20s"
lock_ttl: "5m" lock_ttl: "5m"
batch_size: 10000 batch_size: 10000
app_codes: ["lalu","fami","huwaa"]
game_level_event_relay: game_level_event_relay:
enabled: true enabled: true
interval: "5s" interval: "5s"
timeout: "10s" timeout: "10s"
lock_ttl: "30s" lock_ttl: "30s"
batch_size: 100 batch_size: 100
app_codes: ["lalu","fami","huwaa"]
mic_open_session_compensation: mic_open_session_compensation:
enabled: true enabled: true
interval: "60s" interval: "60s"
@ -107,7 +95,6 @@ tasks:
batch_size: 100 batch_size: 100
pending_publish_max_age: "2m" pending_publish_max_age: "2m"
publishing_session_max_age: "12h" publishing_session_max_age: "12h"
app_codes: ["lalu","fami","huwaa"]
room_open_session_compensation: room_open_session_compensation:
enabled: true enabled: true
interval: "60s" interval: "60s"
@ -115,18 +102,15 @@ tasks:
lock_ttl: "60s" lock_ttl: "60s"
batch_size: 200 batch_size: 200
open_session_max_age: "24h" open_session_max_age: "24h"
app_codes: ["lalu","fami","huwaa"]
manager_user_block_expiry: manager_user_block_expiry:
enabled: true enabled: true
interval: "30s" interval: "30s"
timeout: "10s" timeout: "10s"
lock_ttl: "30s" lock_ttl: "30s"
batch_size: 100 batch_size: 100
app_codes: ["lalu","fami","huwaa"]
admin_user_ban_expiry: admin_user_ban_expiry:
enabled: true enabled: true
interval: "30s" interval: "30s"
timeout: "10s" timeout: "10s"
lock_ttl: "30s" lock_ttl: "30s"
batch_size: 100 batch_size: 100
app_codes: ["lalu","fami","huwaa"]

View File

@ -110,6 +110,7 @@ func New(cfg config.Config) (*App, error) {
} }
userCron := integration.NewUserCronClient(userv1.NewUserCronServiceClient(userConn)) userCron := integration.NewUserCronClient(userv1.NewUserCronServiceClient(userConn))
appRegistry := integration.NewAppRegistryClient(userv1.NewAppRegistryServiceClient(userConn))
activityCron := integration.NewActivityCronClient(activityv1.NewActivityCronServiceClient(activityConn)) activityCron := integration.NewActivityCronClient(activityv1.NewActivityCronServiceClient(activityConn))
gameCron := integration.NewGameCronClient(gamev1.NewGameCronServiceClient(gameConn)) gameCron := integration.NewGameCronClient(gamev1.NewGameCronServiceClient(gameConn))
walletCron := integration.NewWalletCronClient(walletv1.NewWalletCronServiceClient(walletConn)) walletCron := integration.NewWalletCronClient(walletv1.NewWalletCronServiceClient(walletConn))
@ -136,7 +137,7 @@ func New(cfg config.Config) (*App, error) {
"vip_daily_coin_rebate": walletCron.ProcessVIPDailyCoinRebateBatch, "vip_daily_coin_rebate": walletCron.ProcessVIPDailyCoinRebateBatch,
"host_salary_half_month_settlement": walletCron.ProcessHostSalaryHalfMonthSettlementBatch, "host_salary_half_month_settlement": walletCron.ProcessHostSalaryHalfMonthSettlementBatch,
"host_salary_month_end": walletCron.ProcessHostSalaryMonthEndBatch, "host_salary_month_end": walletCron.ProcessHostSalaryMonthEndBatch,
}) }, appRegistry)
return &App{ return &App{
server: server, server: server,

View File

@ -4,13 +4,10 @@ package config
import ( import (
"strings" "strings"
"hyapp/pkg/appcode"
"hyapp/pkg/configx" "hyapp/pkg/configx"
"hyapp/pkg/logx" "hyapp/pkg/logx"
) )
const defaultAppCode = "lalu"
// Config describes cron-service startup and schedule settings. // Config describes cron-service startup and schedule settings.
type Config struct { type Config struct {
// ServiceName is written into logs and health metadata. // ServiceName is written into logs and health metadata.
@ -51,8 +48,6 @@ type TaskConfig struct {
LockTTL string `yaml:"lock_ttl"` LockTTL string `yaml:"lock_ttl"`
// BatchSize is passed to owner service batch RPCs. // BatchSize is passed to owner service batch RPCs.
BatchSize int `yaml:"batch_size"` BatchSize int `yaml:"batch_size"`
// AppCodes scopes the task by tenant; every app_code gets an independent run.
AppCodes []string `yaml:"app_codes"`
// PendingPublishMaxAge is used by mic_open_session_compensation for pending_publish sessions. // PendingPublishMaxAge is used by mic_open_session_compensation for pending_publish sessions.
PendingPublishMaxAge string `yaml:"pending_publish_max_age"` PendingPublishMaxAge string `yaml:"pending_publish_max_age"`
// PublishingSessionMaxAge is used by mic_open_session_compensation for publishing sessions. // PublishingSessionMaxAge is used by mic_open_session_compensation for publishing sessions.
@ -132,7 +127,6 @@ func defaultTasks() map[string]TaskConfig {
Timeout: "3s", Timeout: "3s",
LockTTL: "30s", LockTTL: "30s",
BatchSize: 100, BatchSize: 100,
AppCodes: []string{defaultAppCode},
}, },
"user_region_rebuild": { "user_region_rebuild": {
Enabled: true, Enabled: true,
@ -140,7 +134,6 @@ func defaultTasks() map[string]TaskConfig {
Timeout: "5s", Timeout: "5s",
LockTTL: "30s", LockTTL: "30s",
BatchSize: 500, BatchSize: 500,
AppCodes: []string{defaultAppCode},
}, },
"message_fanout": { "message_fanout": {
Enabled: true, Enabled: true,
@ -148,7 +141,6 @@ func defaultTasks() map[string]TaskConfig {
Timeout: "10s", Timeout: "10s",
LockTTL: "30s", LockTTL: "30s",
BatchSize: 500, BatchSize: 500,
AppCodes: []string{defaultAppCode},
}, },
"vip_daily_coin_rebate": { "vip_daily_coin_rebate": {
// scheduler 启动即执行,因此不能用 24h 间隔假装 UTC 0 点;每分钟触发一次, // scheduler 启动即执行,因此不能用 24h 间隔假装 UTC 0 点;每分钟触发一次,
@ -158,7 +150,6 @@ func defaultTasks() map[string]TaskConfig {
Timeout: "30s", Timeout: "30s",
LockTTL: "1m", LockTTL: "1m",
BatchSize: 500, BatchSize: 500,
AppCodes: []string{"lalu", "fami"},
}, },
"growth_level_reward": { "growth_level_reward": {
Enabled: true, Enabled: true,
@ -166,7 +157,6 @@ func defaultTasks() map[string]TaskConfig {
Timeout: "10s", Timeout: "10s",
LockTTL: "30s", LockTTL: "30s",
BatchSize: 100, BatchSize: 100,
AppCodes: []string{defaultAppCode},
}, },
"temporary_growth_level": { "temporary_growth_level": {
Enabled: true, Enabled: true,
@ -174,7 +164,6 @@ func defaultTasks() map[string]TaskConfig {
Timeout: "15s", Timeout: "15s",
LockTTL: "30s", LockTTL: "30s",
BatchSize: 100, BatchSize: 100,
AppCodes: []string{defaultAppCode},
}, },
"achievement_reward": { "achievement_reward": {
Enabled: true, Enabled: true,
@ -182,7 +171,6 @@ func defaultTasks() map[string]TaskConfig {
Timeout: "10s", Timeout: "10s",
LockTTL: "30s", LockTTL: "30s",
BatchSize: 100, BatchSize: 100,
AppCodes: []string{defaultAppCode},
}, },
"weekly_star_settlement": { "weekly_star_settlement": {
Enabled: true, Enabled: true,
@ -190,7 +178,6 @@ func defaultTasks() map[string]TaskConfig {
Timeout: "20s", Timeout: "20s",
LockTTL: "1m", LockTTL: "1m",
BatchSize: 50, BatchSize: 50,
AppCodes: []string{defaultAppCode},
}, },
"activity_template_settlement": { "activity_template_settlement": {
Enabled: true, Enabled: true,
@ -198,7 +185,6 @@ func defaultTasks() map[string]TaskConfig {
Timeout: "30s", Timeout: "30s",
LockTTL: "1m", LockTTL: "1m",
BatchSize: 50, BatchSize: 50,
AppCodes: []string{defaultAppCode},
}, },
"agency_opening_settlement": { "agency_opening_settlement": {
Enabled: true, Enabled: true,
@ -206,7 +192,6 @@ func defaultTasks() map[string]TaskConfig {
Timeout: "20s", Timeout: "20s",
LockTTL: "1m", LockTTL: "1m",
BatchSize: 50, BatchSize: 50,
AppCodes: []string{defaultAppCode},
}, },
"cp_weekly_rank_settlement": { "cp_weekly_rank_settlement": {
Enabled: true, Enabled: true,
@ -214,7 +199,6 @@ func defaultTasks() map[string]TaskConfig {
Timeout: "30s", Timeout: "30s",
LockTTL: "5m", LockTTL: "5m",
BatchSize: 50, BatchSize: 50,
AppCodes: []string{defaultAppCode},
}, },
"room_turnover_reward_settlement": { "room_turnover_reward_settlement": {
Enabled: true, Enabled: true,
@ -222,7 +206,6 @@ func defaultTasks() map[string]TaskConfig {
Timeout: "30s", Timeout: "30s",
LockTTL: "5m", LockTTL: "5m",
BatchSize: 100, BatchSize: 100,
AppCodes: []string{defaultAppCode},
}, },
"cp_intimacy_leaderboard": { "cp_intimacy_leaderboard": {
Enabled: true, Enabled: true,
@ -230,7 +213,6 @@ func defaultTasks() map[string]TaskConfig {
Timeout: "20s", Timeout: "20s",
LockTTL: "5m", LockTTL: "5m",
BatchSize: 10000, BatchSize: 10000,
AppCodes: []string{defaultAppCode},
}, },
"game_level_event_relay": { "game_level_event_relay": {
Enabled: true, Enabled: true,
@ -238,7 +220,6 @@ func defaultTasks() map[string]TaskConfig {
Timeout: "10s", Timeout: "10s",
LockTTL: "30s", LockTTL: "30s",
BatchSize: 100, BatchSize: 100,
AppCodes: []string{defaultAppCode},
}, },
"host_salary_daily_settlement": { "host_salary_daily_settlement": {
Enabled: true, Enabled: true,
@ -246,7 +227,6 @@ func defaultTasks() map[string]TaskConfig {
Timeout: "30s", Timeout: "30s",
LockTTL: "2m", LockTTL: "2m",
BatchSize: 200, BatchSize: 200,
AppCodes: []string{defaultAppCode},
}, },
"host_salary_half_month_settlement": { "host_salary_half_month_settlement": {
Enabled: true, Enabled: true,
@ -254,7 +234,6 @@ func defaultTasks() map[string]TaskConfig {
Timeout: "30s", Timeout: "30s",
LockTTL: "2m", LockTTL: "2m",
BatchSize: 200, BatchSize: 200,
AppCodes: []string{defaultAppCode},
}, },
"host_salary_month_end": { "host_salary_month_end": {
Enabled: true, Enabled: true,
@ -262,7 +241,6 @@ func defaultTasks() map[string]TaskConfig {
Timeout: "60s", Timeout: "60s",
LockTTL: "5m", LockTTL: "5m",
BatchSize: 200, BatchSize: 200,
AppCodes: []string{defaultAppCode},
}, },
"mic_open_session_compensation": { "mic_open_session_compensation": {
Enabled: true, Enabled: true,
@ -270,7 +248,6 @@ func defaultTasks() map[string]TaskConfig {
Timeout: "10s", Timeout: "10s",
LockTTL: "60s", LockTTL: "60s",
BatchSize: 100, BatchSize: 100,
AppCodes: []string{defaultAppCode},
PendingPublishMaxAge: "2m", PendingPublishMaxAge: "2m",
PublishingSessionMaxAge: "12h", PublishingSessionMaxAge: "12h",
}, },
@ -280,7 +257,6 @@ func defaultTasks() map[string]TaskConfig {
Timeout: "10s", Timeout: "10s",
LockTTL: "60s", LockTTL: "60s",
BatchSize: 200, BatchSize: 200,
AppCodes: []string{defaultAppCode},
// stale worker 2 分钟兜底断线离房24h 只兜 room-service 节点崩溃或 outbox 丢失的极端场景。 // stale worker 2 分钟兜底断线离房24h 只兜 room-service 节点崩溃或 outbox 丢失的极端场景。
OpenSessionMaxAge: "24h", OpenSessionMaxAge: "24h",
}, },
@ -290,7 +266,6 @@ func defaultTasks() map[string]TaskConfig {
Timeout: "10s", Timeout: "10s",
LockTTL: "30s", LockTTL: "30s",
BatchSize: 100, BatchSize: 100,
AppCodes: []string{defaultAppCode},
}, },
"admin_user_ban_expiry": { "admin_user_ban_expiry": {
Enabled: true, Enabled: true,
@ -298,7 +273,6 @@ func defaultTasks() map[string]TaskConfig {
Timeout: "10s", Timeout: "10s",
LockTTL: "30s", LockTTL: "30s",
BatchSize: 100, BatchSize: 100,
AppCodes: []string{defaultAppCode},
}, },
} }
} }
@ -322,7 +296,6 @@ func normalizeTasks(tasks map[string]TaskConfig) map[string]TaskConfig {
Timeout: "5s", Timeout: "5s",
LockTTL: "30s", LockTTL: "30s",
BatchSize: 100, BatchSize: 100,
AppCodes: []string{defaultAppCode},
}, task) }, task)
} }
return tasks return tasks
@ -356,23 +329,5 @@ func mergeTaskConfig(def TaskConfig, current TaskConfig) TaskConfig {
if current.BatchSize <= 0 { if current.BatchSize <= 0 {
current.BatchSize = def.BatchSize current.BatchSize = def.BatchSize
} }
current.AppCodes = normalizeAppCodes(current.AppCodes)
if len(current.AppCodes) == 0 {
current.AppCodes = def.AppCodes
}
return current return current
} }
func normalizeAppCodes(values []string) []string {
seen := map[string]bool{}
result := make([]string, 0, len(values))
for _, value := range values {
code := appcode.Normalize(value)
if code == "" || seen[code] {
continue
}
seen[code] = true
result = append(result, code)
}
return result
}

View File

@ -33,12 +33,9 @@ func TestDefaultCPIntimacyLeaderboardCronTaskRunsEveryFiveMinutes(t *testing.T)
} }
} }
func TestDefaultVIPDailyCoinRebatePollsBothApps(t *testing.T) { func TestDefaultVIPDailyCoinRebateIsEnabled(t *testing.T) {
task, ok := Default().Tasks["vip_daily_coin_rebate"] task, ok := Default().Tasks["vip_daily_coin_rebate"]
if !ok || !task.Enabled || task.Interval != "1m" || task.BatchSize != 500 { if !ok || !task.Enabled || task.Interval != "1m" || task.BatchSize != 500 {
t.Fatalf("VIP daily coin rebate task mismatch: %+v", task) t.Fatalf("VIP daily coin rebate task mismatch: %+v", task)
} }
if len(task.AppCodes) != 2 || task.AppCodes[0] != "lalu" || task.AppCodes[1] != "fami" {
t.Fatalf("VIP rebate must be app-scoped for both configured apps: %+v", task.AppCodes)
}
} }

View File

@ -0,0 +1,30 @@
package integration
import (
"context"
userv1 "hyapp.local/api/proto/user/v1"
)
// AppRegistryClient keeps tenant discovery behind user-service, the owner of the App registry.
type AppRegistryClient struct {
client userv1.AppRegistryServiceClient
}
func NewAppRegistryClient(client userv1.AppRegistryServiceClient) *AppRegistryClient {
return &AppRegistryClient{client: client}
}
func (c *AppRegistryClient) ListEnabledAppCodes(ctx context.Context) ([]string, error) {
resp, err := c.client.ListApps(ctx, &userv1.ListAppsRequest{})
if err != nil {
return nil, err
}
appCodes := make([]string, 0, len(resp.GetApps()))
for _, app := range resp.GetApps() {
if app.GetStatus() == "active" {
appCodes = append(appCodes, app.GetAppCode())
}
}
return appCodes, nil
}

View File

@ -3,6 +3,7 @@ package scheduler
import ( import (
"context" "context"
"errors"
"log/slog" "log/slog"
"sort" "sort"
"strings" "strings"
@ -53,22 +54,35 @@ type BatchResult struct {
// Handler executes one batch through an owner service client. // Handler executes one batch through an owner service client.
type Handler func(ctx context.Context, request BatchRequest) (BatchResult, error) type Handler func(ctx context.Context, request BatchRequest) (BatchResult, error)
// AppCodeProvider 返回当前启用租户;调度器缓存短时间快照,同时会自动发现后续启用的 App。
type AppCodeProvider interface {
ListEnabledAppCodes(ctx context.Context) ([]string, error)
}
// Scheduler owns task loops. It never implements domain logic directly. // Scheduler owns task loops. It never implements domain logic directly.
type Scheduler struct { type Scheduler struct {
nodeID string nodeID string
tasks map[string]config.TaskConfig tasks map[string]config.TaskConfig
store Store store Store
handlers map[string]Handler handlers map[string]Handler
appCodes AppCodeProvider
appMu sync.Mutex
appCache []string
appUntil time.Time
} }
// New creates a scheduler with explicit handlers for tasks that are already migrated. // New creates a scheduler with explicit handlers for tasks that are already migrated.
func New(nodeID string, tasks map[string]config.TaskConfig, store Store, handlers map[string]Handler) *Scheduler { func New(nodeID string, tasks map[string]config.TaskConfig, store Store, handlers map[string]Handler, providers ...AppCodeProvider) *Scheduler {
return &Scheduler{ scheduler := &Scheduler{
nodeID: strings.TrimSpace(nodeID), nodeID: strings.TrimSpace(nodeID),
tasks: tasks, tasks: tasks,
store: store, store: store,
handlers: handlers, handlers: handlers,
} }
if len(providers) > 0 {
scheduler.appCodes = providers[0]
}
return scheduler
} }
// Run starts one loop per enabled task and app_code until ctx is cancelled. // Run starts one loop per enabled task and app_code until ctx is cancelled.
@ -94,20 +108,14 @@ func (s *Scheduler) Run(ctx context.Context) {
logx.Warn(ctx, "cron_task_handler_missing", slog.String("task_name", taskName)) logx.Warn(ctx, "cron_task_handler_missing", slog.String("task_name", taskName))
continue continue
} }
for _, appCode := range task.AppCodes {
taskName := taskName
appCode := appCode
task := task
handler := handler
wg.Go(func() { wg.Go(func() {
s.runTaskLoop(ctx, taskName, appCode, task, handler) s.runTaskLoop(ctx, taskName, task, handler)
}) })
} }
}
wg.Wait() wg.Wait()
} }
func (s *Scheduler) runTaskLoop(ctx context.Context, taskName string, appCode string, task config.TaskConfig, handler Handler) { func (s *Scheduler) runTaskLoop(ctx context.Context, taskName string, task config.TaskConfig, handler Handler) {
interval := parseDuration(task.Interval, 5*time.Second) interval := parseDuration(task.Interval, 5*time.Second)
timer := time.NewTimer(0) timer := time.NewTimer(0)
defer timer.Stop() defer timer.Stop()
@ -117,7 +125,27 @@ func (s *Scheduler) runTaskLoop(ctx context.Context, taskName string, appCode st
case <-ctx.Done(): case <-ctx.Done():
return return
case <-timer.C: case <-timer.C:
hasMore := s.runOnce(ctx, taskName, appCode, task, handler) appCodes, err := s.enabledAppCodes(ctx)
if err != nil {
logx.Error(ctx, "cron_app_registry_failed", err, slog.String("task_name", taskName))
timer.Reset(interval)
continue
}
// 每个 app_code 仍使用独立租约和运行记录;这里只把租户来源从 YAML 改成启用 App 注册表。
results := make(chan bool, len(appCodes))
var batch sync.WaitGroup
for _, appCode := range appCodes {
appCode := appCode
batch.Go(func() {
results <- s.runOnce(ctx, taskName, appCode, task, handler)
})
}
batch.Wait()
close(results)
hasMore := false
for result := range results {
hasMore = hasMore || result
}
if hasMore { if hasMore {
timer.Reset(0) timer.Reset(0)
continue continue
@ -127,6 +155,38 @@ func (s *Scheduler) runTaskLoop(ctx context.Context, taskName string, appCode st
} }
} }
func (s *Scheduler) enabledAppCodes(ctx context.Context) ([]string, error) {
s.appMu.Lock()
defer s.appMu.Unlock()
if len(s.appCache) > 0 && time.Now().UTC().Before(s.appUntil) {
return append([]string(nil), s.appCache...), nil
}
if s.appCodes == nil {
return nil, errors.New("enabled app registry is not configured")
}
values, err := s.appCodes.ListEnabledAppCodes(ctx)
if err != nil {
return nil, err
}
seen := make(map[string]bool, len(values))
appCodes := make([]string, 0, len(values))
for _, value := range values {
value = strings.ToLower(strings.TrimSpace(value))
if value == "" || seen[value] {
continue
}
seen[value] = true
appCodes = append(appCodes, value)
}
sort.Strings(appCodes)
if len(appCodes) == 0 {
return nil, errors.New("enabled app registry returned no tenants")
}
s.appCache = append(s.appCache[:0], appCodes...)
s.appUntil = time.Now().UTC().Add(30 * time.Second)
return append([]string(nil), appCodes...), nil
}
func (s *Scheduler) runOnce(ctx context.Context, taskName string, appCode string, task config.TaskConfig, handler Handler) bool { func (s *Scheduler) runOnce(ctx context.Context, taskName string, appCode string, task config.TaskConfig, handler Handler) bool {
if s.store == nil { if s.store == nil {
logx.Error(ctx, "cron_store_missing", nil, slog.String("task_name", taskName), slog.String("app_code", appCode)) logx.Error(ctx, "cron_store_missing", nil, slog.String("task_name", taskName), slog.String("app_code", appCode))

View File

@ -18,6 +18,12 @@ type synctestStore struct {
finished []mysqlstorage.TaskRun finished []mysqlstorage.TaskRun
} }
type staticAppCodeProvider []string
func (p staticAppCodeProvider) ListEnabledAppCodes(context.Context) ([]string, error) {
return append([]string(nil), p...), nil
}
func (s *synctestStore) TryAcquireTaskLease(_ context.Context, _ string, _ string, _ string, _ int64, _ time.Duration) (bool, error) { func (s *synctestStore) TryAcquireTaskLease(_ context.Context, _ string, _ string, _ string, _ int64, _ time.Duration) (bool, error) {
s.mu.Lock() s.mu.Lock()
defer s.mu.Unlock() defer s.mu.Unlock()
@ -52,7 +58,6 @@ func TestSchedulerRunUsesSynctestClock(t *testing.T) {
s := New("node-a", map[string]config.TaskConfig{ s := New("node-a", map[string]config.TaskConfig{
"room_outbox": { "room_outbox": {
Enabled: true, Enabled: true,
AppCodes: []string{"lalu"},
Interval: "1s", Interval: "1s",
Timeout: "100ms", Timeout: "100ms",
LockTTL: "5s", LockTTL: "5s",
@ -68,7 +73,7 @@ func TestSchedulerRunUsesSynctestClock(t *testing.T) {
runTimes = append(runTimes, time.Now()) runTimes = append(runTimes, time.Now())
return BatchResult{ProcessedCount: 1}, nil return BatchResult{ProcessedCount: 1}, nil
}, },
}) }, staticAppCodeProvider{"lalu"})
go s.Run(ctx) go s.Run(ctx)
synctest.Wait() synctest.Wait()
@ -99,3 +104,21 @@ func TestSchedulerRunUsesSynctestClock(t *testing.T) {
} }
}) })
} }
func TestSchedulerDiscoversEveryEnabledTenant(t *testing.T) {
s := New("node-a", nil, nil, nil, staticAppCodeProvider{" huwaa ", "lalu", "huwaa", "fami", ""})
appCodes, err := s.enabledAppCodes(context.Background())
if err != nil {
t.Fatalf("enabledAppCodes failed: %v", err)
}
want := []string{"fami", "huwaa", "lalu"}
if len(appCodes) != len(want) {
t.Fatalf("app code count mismatch: got=%v want=%v", appCodes, want)
}
for index := range want {
if appCodes[index] != want[index] {
t.Fatalf("app code mismatch: got=%v want=%v", appCodes, want)
}
}
}

View File

@ -35,6 +35,8 @@ type leaderccAdapterConfig struct {
UIDMode string `json:"uid_mode"` UIDMode string `json:"uid_mode"`
DefaultLang string `json:"default_lang"` DefaultLang string `json:"default_lang"`
TokenTTLSeconds int64 `json:"token_ttl_seconds"` TokenTTLSeconds int64 `json:"token_ttl_seconds"`
// game_urls 由后台灵仙列表同步写入,解决同一平台下每款游戏 H5 地址不同的问题。
GameURLs map[string]string `json:"game_urls"`
} }
func leaderccConfigFromPlatform(value any) leaderccAdapterConfig { func leaderccConfigFromPlatform(value any) leaderccAdapterConfig {
@ -50,9 +52,19 @@ func leaderccConfigFromPlatform(value any) leaderccAdapterConfig {
_ = json.Unmarshal([]byte(strings.TrimSpace(raw)), &config) _ = json.Unmarshal([]byte(strings.TrimSpace(raw)), &config)
config.UIDMode = strings.ToLower(strings.TrimSpace(config.UIDMode)) config.UIDMode = strings.ToLower(strings.TrimSpace(config.UIDMode))
config.DefaultLang = strings.TrimSpace(config.DefaultLang) config.DefaultLang = strings.TrimSpace(config.DefaultLang)
config.GameURLs = normalizeStringMap(config.GameURLs)
return config return config
} }
func leaderccLaunchBaseURL(game gamedomain.LaunchableGame, config leaderccAdapterConfig) string {
for _, key := range []string{game.ProviderGameID, game.GameID, strings.ToLower(game.GameID)} {
if raw := strings.TrimSpace(config.GameURLs[strings.TrimSpace(key)]); raw != "" {
return raw
}
}
return ""
}
func (c leaderccAdapterConfig) TokenTTL() time.Duration { func (c leaderccAdapterConfig) TokenTTL() time.Duration {
// 灵仙启动后会持续用 token 调后端接口,默认给 24 小时,后台可通过 JSON 覆盖。 // 灵仙启动后会持续用 token 调后端接口,默认给 24 小时,后台可通过 JSON 覆盖。
if c.TokenTTLSeconds > 0 { if c.TokenTTLSeconds > 0 {

View File

@ -827,10 +827,15 @@ func normalizeRecentGamesPageSize(pageSize int32) int32 {
func buildLaunchURL(game gamedomain.LaunchableGame, session gamedomain.LaunchSession, token string) (string, error) { func buildLaunchURL(game gamedomain.LaunchableGame, session gamedomain.LaunchSession, token string) (string, error) {
base := strings.TrimSpace(game.APIBaseURL) base := strings.TrimSpace(game.APIBaseURL)
if strings.EqualFold(game.AdapterType, gamedomain.AdapterLeaderCCV1) && base == "" { if strings.EqualFold(game.AdapterType, gamedomain.AdapterLeaderCCV1) {
// 灵仙当前只需要我们提供回调域名和接口,不要求服务端拼 H5/Auth URL。 // 灵仙不同游戏使用独立 H5 地址;保留平台 URL 作为旧配置兜底。
if configuredBase := leaderccLaunchBaseURL(game, leaderccConfigFromPlatform(game)); configuredBase != "" {
base = configuredBase
}
if base == "" {
return "", nil return "", nil
} }
}
if strings.EqualFold(game.AdapterType, gamedomain.AdapterZeeOneV1) { if strings.EqualFold(game.AdapterType, gamedomain.AdapterZeeOneV1) {
// ZeeOne 的测试/正式游戏经常是同商户多条 H5 地址,优先读后台 JSON 的按游戏覆盖配置。 // ZeeOne 的测试/正式游戏经常是同商户多条 H5 地址,优先读后台 JSON 的按游戏覆盖配置。
if configuredBase := zeeoneLaunchBaseURL(game, zeeoneConfigFromPlatform(game)); configuredBase != "" { if configuredBase := zeeoneLaunchBaseURL(game, zeeoneConfigFromPlatform(game)); configuredBase != "" {

View File

@ -277,6 +277,45 @@ func TestLaunchGameBuildsLeaderCCURL(t *testing.T) {
} }
} }
func TestLaunchGameUsesLeaderCCPerGameURL(t *testing.T) {
repo := &fakeRepository{
launchable: gamedomain.LaunchableGame{
CatalogItem: gamedomain.CatalogItem{
AppCode: "lalu", GameID: "lingxian_9", PlatformCode: "lingxian",
ProviderGameID: "9", GameName: "GREEDY BOX", Status: gamedomain.StatusActive,
},
PlatformStatus: gamedomain.StatusActive,
AdapterType: gamedomain.AdapterLeaderCCV1,
AdapterConfigJSON: `{
"default_lang":"en","uid_mode":"display_user_id",
"game_urls":{"9":"https://gztest.leadercc.com/lalu_games/greedy_box_half/index.html?pl=lalu"}
}`,
},
}
svc := New(Config{LaunchSessionTTL: 15 * time.Minute}, repo, &fakeWallet{}, &fakeUser{})
svc.now = func() time.Time { return time.UnixMilli(1700000000000) }
result, err := svc.LaunchGame(context.Background(), LaunchCommand{
AppCode: "lalu", UserID: 42, DisplayUserID: "420001", GameID: "lingxian_9",
RoomID: "room_1", AccessToken: "app_access_token_lingxian",
})
if err != nil {
t.Fatalf("LaunchGame failed: %v", err)
}
parsed, err := url.Parse(result.LaunchURL)
if err != nil {
t.Fatalf("parse launch URL: %v", err)
}
if parsed.Host != "gztest.leadercc.com" || parsed.Path != "/lalu_games/greedy_box_half/index.html" {
t.Fatalf("launch base mismatch: %s", result.LaunchURL)
}
for key, want := range map[string]string{"pl": "lalu", "uid": "420001", "token": "app_access_token_lingxian", "lang": "en", "roomid": "room_1"} {
if got := parsed.Query().Get(key); got != want {
t.Fatalf("query %s = %q, want %q URL=%s", key, got, want, result.LaunchURL)
}
}
}
func TestLaunchGameBuildsZeeOneURL(t *testing.T) { func TestLaunchGameBuildsZeeOneURL(t *testing.T) {
repo := &fakeRepository{ repo := &fakeRepository{
launchable: gamedomain.LaunchableGame{ launchable: gamedomain.LaunchableGame{

View File

@ -27,6 +27,8 @@ type Claims struct {
AppCode string AppCode string
UserID int64 UserID int64
SessionID string SessionID string
// DeviceID 是 user-service 从 auth_session 签入 access token 的设备绑定;旧 token 缺字段时保持为空。
DeviceID string
ProfileCompleted bool ProfileCompleted bool
OnboardingStatus string OnboardingStatus string
AccessTokenExpiresAtMS int64 AccessTokenExpiresAtMS int64
@ -93,6 +95,7 @@ func (v *Verifier) Verify(header string) (Claims, error) {
profileCompleted, _ := claims["profile_completed"].(bool) profileCompleted, _ := claims["profile_completed"].(bool)
onboardingStatus, _ := claims["onboarding_status"].(string) onboardingStatus, _ := claims["onboarding_status"].(string)
sessionID, _ := claims["sid"].(string) sessionID, _ := claims["sid"].(string)
deviceID, _ := claims["device_id"].(string)
appCode, _ := claims["app_code"].(string) appCode, _ := claims["app_code"].(string)
expiresAtMS := claimUnixSecondsMS(claims["exp"]) expiresAtMS := claimUnixSecondsMS(claims["exp"])
@ -100,6 +103,7 @@ func (v *Verifier) Verify(header string) (Claims, error) {
AppCode: appcode.Normalize(appCode), AppCode: appcode.Normalize(appCode),
UserID: userID, UserID: userID,
SessionID: strings.TrimSpace(sessionID), SessionID: strings.TrimSpace(sessionID),
DeviceID: strings.TrimSpace(deviceID),
ProfileCompleted: profileCompleted, ProfileCompleted: profileCompleted,
OnboardingStatus: strings.TrimSpace(onboardingStatus), OnboardingStatus: strings.TrimSpace(onboardingStatus),
AccessTokenExpiresAtMS: expiresAtMS, AccessTokenExpiresAtMS: expiresAtMS,
@ -159,6 +163,13 @@ func SessionIDFromContext(ctx context.Context) string {
return strings.TrimSpace(claims.SessionID) return strings.TrimSpace(claims.SessionID)
} }
// DeviceIDFromContext 只返回已验签 access token 的设备 claim。
// 不从请求体、header 或 session_id 兜底,否则 dynamic_v3 设备日风控可被轻易分裂。
func DeviceIDFromContext(ctx context.Context) string {
claims, _ := ctx.Value(claimsContextKey).(Claims)
return strings.TrimSpace(claims.DeviceID)
}
// ProfileCompletedFromContext 返回 gateway profile gate 的判断依据。 // ProfileCompletedFromContext 返回 gateway profile gate 的判断依据。
func ProfileCompletedFromContext(ctx context.Context) bool { func ProfileCompletedFromContext(ctx context.Context) bool {
claims, _ := ctx.Value(claimsContextKey).(Claims) claims, _ := ctx.Value(claimsContextKey).(Claims)

View File

@ -1,6 +1,7 @@
package auth package auth
import ( import (
"context"
"errors" "errors"
"strconv" "strconv"
"testing" "testing"
@ -16,6 +17,7 @@ func TestVerifierParsesLargeUserIDStringWithoutPrecisionLoss(t *testing.T) {
token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{ token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
"user_id": strconv.FormatInt(userID, 10), "user_id": strconv.FormatInt(userID, 10),
"app_code": "lalu", "app_code": "lalu",
"device_id": " device-auth-1 ",
}) })
signed, err := token.SignedString([]byte("secret")) signed, err := token.SignedString([]byte("secret"))
if err != nil { if err != nil {
@ -29,6 +31,32 @@ func TestVerifierParsesLargeUserIDStringWithoutPrecisionLoss(t *testing.T) {
if claims.UserID != userID { if claims.UserID != userID {
t.Fatalf("user_id lost precision: got %d want %d", claims.UserID, userID) t.Fatalf("user_id lost precision: got %d want %d", claims.UserID, userID)
} }
if claims.DeviceID != "device-auth-1" {
t.Fatalf("device_id was not normalized from signed claim: got %q", claims.DeviceID)
}
}
func TestVerifierKeepsLegacyTokenWithoutDeviceIDCompatible(t *testing.T) {
t.Parallel()
// 存量 access token 在滚动升级期不携带 device_idgateway 仍应允许其进入 fixed_v2
// 但绝不得把 sid 自动填为设备dynamic_v3 会在 owner 处对空设备 fail-close。
token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
"user_id": strconv.FormatInt(42, 10),
"sid": "sess-must-not-be-device",
})
signed, err := token.SignedString([]byte("secret"))
if err != nil {
t.Fatalf("sign legacy token failed: %v", err)
}
claims, err := NewVerifier("secret").Verify("Bearer " + signed)
if err != nil {
t.Fatalf("Verify legacy token failed: %v", err)
}
ctx := WithClaims(context.Background(), claims)
if got := DeviceIDFromContext(ctx); got != "" {
t.Fatalf("legacy token device_id=%q, want empty instead of sid fallback", got)
}
} }
func TestVerifierDistinguishesExpiredAccessTokenFromInvalidCredentials(t *testing.T) { func TestVerifierDistinguishesExpiredAccessTokenFromInvalidCredentials(t *testing.T) {

View File

@ -242,6 +242,8 @@ func RoomMeta(request *http.Request, roomID string, commandID string) *roomv1.Re
AppCode: appcode.FromContext(request.Context()), AppCode: appcode.FromContext(request.Context()),
GatewayNodeId: "gateway-local", GatewayNodeId: "gateway-local",
SessionId: sessionID, SessionId: sessionID,
// 设备作用域只从已验签 JWT 读取;旧 JWT 返回空值,不用临时 session_id 兜底。
DeviceId: auth.DeviceIDFromContext(request.Context()),
SentAtMs: time.Now().UnixMilli(), SentAtMs: time.Now().UnixMilli(),
} }
} }

View File

@ -0,0 +1,29 @@
package httpkit
import (
"net/http/httptest"
"testing"
"hyapp/services/gateway-service/internal/auth"
)
func TestRoomMetaUsesOnlySignedJWTDeviceClaim(t *testing.T) {
request := httptest.NewRequest("POST", "/api/v1/rooms/gift/send", nil)
request = request.WithContext(auth.WithClaims(request.Context(), auth.Claims{
AppCode: "lalu", UserID: 42, SessionID: "sess-not-a-device", DeviceID: "device-auth-session-42",
}))
meta := RoomMeta(request, "room-1", "cmd-1")
if meta.GetDeviceId() != "device-auth-session-42" || meta.GetSessionId() != "sess-not-a-device" {
t.Fatalf("room meta auth snapshots mismatch: %+v", meta)
}
legacy := httptest.NewRequest("POST", "/api/v1/rooms/gift/send", nil)
legacy = legacy.WithContext(auth.WithClaims(legacy.Context(), auth.Claims{
AppCode: "lalu", UserID: 42, SessionID: "sess-legacy-must-not-be-device",
}))
legacyMeta := RoomMeta(legacy, "room-1", "cmd-legacy")
if legacyMeta.GetDeviceId() != "" {
// 存量 JWT 缺 device_id 时保留空值fixed_v2 兼容dynamic_v3 交给 owner fail-close。
t.Fatalf("legacy room meta derived fake device_id=%q", legacyMeta.GetDeviceId())
}
}

View File

@ -9611,8 +9611,11 @@ func TestVoiceRoomActiveRedPacketRouteIsRegistered(t *testing.T) {
walletClient := &fakeWalletClient{listRedPacketsResp: &walletv1.ListRedPacketsResponse{ walletClient := &fakeWalletClient{listRedPacketsResp: &walletv1.ListRedPacketsResponse{
ServerTimeMs: 4000, ServerTimeMs: 4000,
Packets: []*walletv1.RedPacket{ Packets: []*walletv1.RedPacket{
{PacketId: "packet-active", RoomId: "room-1", RegionId: 1001, SenderUserId: 10002, PacketType: "normal", Status: "active"}, {PacketId: "packet-active", RoomId: "room-1", RegionId: 1001, SenderUserId: 10002, PacketType: "normal", Status: "active", ExpiresAtMs: 5000, RemainingAmount: 100, RemainingCount: 1},
{PacketId: "packet-finished", RoomId: "room-1", RegionId: 1001, SenderUserId: 10003, PacketType: "normal", Status: "finished"}, {PacketId: "packet-finished", RoomId: "room-1", RegionId: 1001, SenderUserId: 10003, PacketType: "normal", Status: "finished"},
{PacketId: "packet-expired", RoomId: "room-1", RegionId: 1001, SenderUserId: 10004, PacketType: "normal", Status: "active", ExpiresAtMs: 4000, RemainingAmount: 100, RemainingCount: 1},
{PacketId: "packet-empty", RoomId: "room-1", RegionId: 1001, SenderUserId: 10005, PacketType: "normal", Status: "active", ExpiresAtMs: 5000, RemainingAmount: 0, RemainingCount: 0},
{PacketId: "packet-claimed", RoomId: "room-1", RegionId: 1001, SenderUserId: 10006, PacketType: "normal", Status: "active", ExpiresAtMs: 5000, RemainingAmount: 100, RemainingCount: 1, ClaimedByViewer: true},
}, },
}} }}
profileClient := &fakeUserProfileClient{regionID: 1001} profileClient := &fakeUserProfileClient{regionID: 1001}
@ -9633,7 +9636,7 @@ func TestVoiceRoomActiveRedPacketRouteIsRegistered(t *testing.T) {
t.Fatalf("active red-packet request mismatch: %+v", walletClient.lastListRedPackets) t.Fatalf("active red-packet request mismatch: %+v", walletClient.lastListRedPackets)
} }
if profileClient.lastBatch == nil || len(profileClient.lastBatch.GetUserIds()) != 1 || profileClient.lastBatch.GetUserIds()[0] != 10002 { if profileClient.lastBatch == nil || len(profileClient.lastBatch.GetUserIds()) != 1 || profileClient.lastBatch.GetUserIds()[0] != 10002 {
t.Fatalf("active red-packet should batch only visible sender profiles: %+v", profileClient.lastBatch) t.Fatalf("active red-packet should batch only currently claimable sender profiles: %+v", profileClient.lastBatch)
} }
var response httpkit.ResponseEnvelope var response httpkit.ResponseEnvelope
if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil { if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil {

View File

@ -5,6 +5,7 @@ import (
"net/http" "net/http"
"strconv" "strconv"
"strings" "strings"
"time"
roomv1 "hyapp.local/api/proto/room/v1" roomv1 "hyapp.local/api/proto/room/v1"
userv1 "hyapp.local/api/proto/user/v1" userv1 "hyapp.local/api/proto/user/v1"
@ -296,10 +297,17 @@ func (h *Handler) listActiveRedPackets(writer http.ResponseWriter, request *http
return return
} }
activePackets := make([]*walletv1.RedPacket, 0, len(resp.GetPackets())) activePackets := make([]*walletv1.RedPacket, 0, len(resp.GetPackets()))
for _, packet := range resp.GetPackets() { serverTimeMS := resp.GetServerTimeMs()
if packet.GetStatus() == "active" || packet.GetStatus() == "waiting_open" { if serverTimeMS <= 0 {
activePackets = append(activePackets, packet) serverTimeMS = time.Now().UTC().UnixMilli()
} }
for _, packet := range resp.GetPackets() {
statusActive := packet.GetStatus() == "active" || packet.GetStatus() == "waiting_open"
// active 是“当前用户仍可领取”列表:状态、有效期、剩余份数和用户领取态必须同时满足。
if !statusActive || packet.GetExpiresAtMs() <= serverTimeMS || packet.GetRemainingCount() <= 0 || packet.GetRemainingAmount() <= 0 || packet.GetClaimedByViewer() {
continue
}
activePackets = append(activePackets, packet)
} }
profiles, err := h.redPacketSenderProfiles(request, activePackets) profiles, err := h.redPacketSenderProfiles(request, activePackets)
if err != nil { if err != nil {

View File

@ -0,0 +1,143 @@
// Command strategy-sim runs the product matrix against the exact pure kernel used by
// production. It performs no database or network I/O; fixed seeds make every report
// reproducible in CI, during review, and after a rule-version change.
package main
import (
"encoding/json"
"flag"
"fmt"
"os"
domain "hyapp/services/lucky-gift-service/internal/domain/luckygift"
)
const defaultSimulationSeed int64 = 20_260_712
type simulationOptions struct {
Seed int64
MonteCarloN int
LongRunN int
GroupLongRunN int
}
type simulationReport struct {
Seed int64 `json:"seed"`
MonteCarloDraws int `json:"monte_carlo_draws"`
LongRunDraws int `json:"long_run_draws"`
GroupLongRunDraws int `json:"group_long_run_draws"`
Nominal nominalEVReport `json:"nominal_probability_table"`
Scenarios []scenarioResult `json:"scenarios"`
AllPassed bool `json:"all_passed"`
}
type nominalEVReport struct {
ExpectedMultiplier float64 `json:"expected_multiplier"`
NominalRTPPercent float64 `json:"nominal_rtp_percent"`
TargetRTPPercent float64 `json:"target_rtp_percent"`
PassesTarget bool `json:"passes_target"`
Conclusion string `json:"conclusion"`
}
type scenarioResult struct {
Name string `json:"name"`
Passed bool `json:"passed"`
Summary string `json:"summary"`
Data any `json:"data,omitempty"`
}
func main() {
options := simulationOptions{}
jsonOut := ""
flag.Int64Var(&options.Seed, "seed", defaultSimulationSeed, "deterministic simulation seed")
flag.IntVar(&options.MonteCarloN, "monte-carlo-draws", 100_000, "draws for probability Monte Carlo")
flag.IntVar(&options.LongRunN, "long-run-draws", 100_000, "draws for funded RTP long run")
flag.IntVar(&options.GroupLongRunN, "group-long-run-draws", 30_000, "draws per recharge-stage group")
flag.StringVar(&jsonOut, "json-out", "", "optional path for the same JSON report printed to stdout")
flag.Parse()
if options.MonteCarloN <= 0 || options.LongRunN <= 0 || options.GroupLongRunN <= 0 {
fmt.Fprintln(os.Stderr, "all draw counts must be positive")
os.Exit(2)
}
report := runSimulation(options)
encoded, err := json.MarshalIndent(report, "", " ")
if err != nil {
fmt.Fprintf(os.Stderr, "encode report: %v\n", err)
os.Exit(1)
}
printHumanSummary(report)
fmt.Println("\n--- JSON REPORT ---")
fmt.Println(string(encoded))
if jsonOut != "" {
if err := os.WriteFile(jsonOut, append(encoded, '\n'), 0o644); err != nil {
fmt.Fprintf(os.Stderr, "write JSON report: %v\n", err)
os.Exit(1)
}
}
if !report.AllPassed {
os.Exit(1)
}
}
func runSimulation(options simulationOptions) simulationReport {
// The runtime default remains a safe 98% table. Only this simulator opts into
// the supplied image's 2650% table so the report can expose why it is unsafe.
config := imageExampleConfig(domain.DefaultLuckyGiftStrategyConfig())
nominal := calculateNominalEV(config)
scenarios := []scenarioResult{
simulateImage800Redraw(config),
simulatePWBoundaries(config),
simulateFundSplitAndColdStart(config),
simulateBatchCounts(config, options.Seed),
simulateSettlementWagerWindow(),
simulateWaterBoundaries(config),
simulateRechargeWindowBoundaries(config),
simulateProbabilityMonteCarlo(config, options.Seed+10, options.MonteCarloN),
simulateFundedRTPLongRun(config, options.Seed+20, options.LongRunN),
simulateMechanismOneTruthTable(config, options.Seed+30),
simulateMechanismTwo(config, options.Seed+40),
simulateJackpotSet(config, options.Seed+50),
simulateDailyJackpotLimit(config, options.Seed+60),
simulateSixRiskCapacities(config),
simulateRechargeStages(config),
simulateCombinationPriority(config, options.Seed+70),
simulateConcurrencyAndIdempotency(config, options.Seed+80),
simulateLongRunGroups(config, options.Seed+90, options.GroupLongRunN),
}
allPassed := !nominal.PassesTarget // 2650% must be explicitly rejected, not relabelled as a 98% pass.
for _, scenario := range scenarios {
allPassed = allPassed && scenario.Passed
}
return simulationReport{
Seed: options.Seed, MonteCarloDraws: options.MonteCarloN, LongRunDraws: options.LongRunN,
GroupLongRunDraws: options.GroupLongRunN, Nominal: nominal, Scenarios: scenarios, AllPassed: allPassed,
}
}
func calculateNominalEV(config domain.StrategyConfig) nominalEVReport {
var expected float64
for _, tier := range config.Tiers {
if tier.Enabled {
expected += float64(tier.BaseWeightPPM) / float64(domain.StrategyPPMScale) * float64(tier.MultiplierPPM) / float64(domain.StrategyPPMScale)
}
}
return nominalEVReport{
ExpectedMultiplier: expected, NominalRTPPercent: expected * 100, TargetRTPPercent: 98,
PassesTarget: expected <= 0.98,
Conclusion: "图示固定概率表的名义EV为26.5x2650%不能作为98% RTP方案只有P/W资金约束后的有资金长跑口径可验证不透支。",
}
}
func printHumanSummary(report simulationReport) {
fmt.Printf("幸运礼物策略模拟 seed=%d\n", report.Seed)
fmt.Printf("名义概率表: EV=%.2fx, RTP=%.2f%%, 98%%校验=%v预期必须为false\n", report.Nominal.ExpectedMultiplier, report.Nominal.NominalRTPPercent, report.Nominal.PassesTarget)
for _, scenario := range report.Scenarios {
status := "PASS"
if !scenario.Passed {
status = "FAIL"
}
fmt.Printf("[%s] %s: %s\n", status, scenario.Name, scenario.Summary)
}
fmt.Printf("总结果: all_passed=%v\n", report.AllPassed)
}

View File

@ -0,0 +1,774 @@
package main
import (
"fmt"
"math"
"sort"
"sync"
domain "hyapp/services/lucky-gift-service/internal/domain/luckygift"
)
type scriptedRandom struct {
indexes []int64
bounds []int64
position int
}
// imageExampleConfig is deliberately local to strategy-sim. Its ordinary weights
// have 26.5x EV and therefore must never be exposed as the domain's runtime default.
func imageExampleConfig(config domain.StrategyConfig) domain.StrategyConfig {
// 模拟显式给出金币分层边界,避免把生产 disabled 草稿的 0/0 sentinel 当成可运行默认值。
config.RechargeStages = []domain.StrategyRechargeStage{
{Name: domain.StageNovice, MinRecharge7DCoins: 0, MinRecharge30DCoins: 0},
{Name: domain.StageNormal, MinRecharge7DCoins: 100, MinRecharge30DCoins: 500},
{Name: domain.StageAdvanced, MinRecharge7DCoins: 500, MinRecharge30DCoins: 2_000},
}
config.Tiers = []domain.StrategyTier{
{ID: "0x", MultiplierPPM: 0, BaseWeightPPM: 600_000, Enabled: true},
{ID: "5x", MultiplierPPM: 5_000_000, BaseWeightPPM: 200_000, Enabled: true},
{ID: "50x", MultiplierPPM: 50_000_000, BaseWeightPPM: 150_000, Enabled: true},
{ID: "200x", MultiplierPPM: 200_000_000, BaseWeightPPM: 40_000, Jackpot: true, JackpotWeight: 4, Enabled: true},
{ID: "500x", MultiplierPPM: 500_000_000, BaseWeightPPM: 0, Jackpot: true, JackpotWeight: 2, Enabled: true},
{ID: "1000x", MultiplierPPM: 1_000_000_000, BaseWeightPPM: 10_000, Jackpot: true, JackpotWeight: 1, Enabled: true},
}
return config
}
func (r *scriptedRandom) Int63n(n int64) (int64, error) {
if r.position >= len(r.indexes) {
return 0, fmt.Errorf("scripted random exhausted at bound %d", n)
}
if len(r.bounds) > r.position && r.bounds[r.position] != n {
return 0, fmt.Errorf("scripted random call %d bound=%d want=%d", r.position, n, r.bounds[r.position])
}
value := r.indexes[r.position]
r.position++
return value, nil
}
// baseProbabilityConfig isolates the image's ordinary probability table. Each
// dynamic/special rule has a separate scenario, so scripted intervals remain exact.
func baseProbabilityConfig(config domain.StrategyConfig) domain.StrategyConfig {
config.LowWaterThresholdCoins = 0
config.HighWaterThresholdCoins = math.MaxInt64
config.LowWaterFactorPPM = domain.StrategyPPMScale
config.HighWaterFactorPPM = domain.StrategyPPMScale
config.RechargeFactorPPM = domain.StrategyPPMScale
config.JackpotMechanism1Enabled = false
config.JackpotMechanism2Enabled = false
config.DailyJackpotLimit = math.MaxInt64
config.MilestoneSpendCoins = 0
return config
}
func failedScenario(name string, err error) scenarioResult {
return scenarioResult{Name: name, Passed: false, Summary: err.Error()}
}
func simulateImage800Redraw(config domain.StrategyConfig) scenarioResult {
const name = "图示800重抽序列"
config = baseProbabilityConfig(config)
random := &scriptedRandom{indexes: []int64{950_000, 359_999, 0}, bounds: []int64{1_000_000, 360_000, 350_000}}
decision, err := domain.DecideLuckyGiftStrategy(config, domain.StrategyState{PoolBalanceCoins: 800}, domain.StrategyInput{GiftPriceCoins: 10}, random)
if err != nil {
return failedScenario(name, err)
}
sequence := make([]string, 0, len(decision.Trace.Draws))
for _, draw := range decision.Trace.Draws {
sequence = append(sequence, draw.TierID)
}
passed := decision.PayoutCoins == 50 && decision.PoolAfterCoins == 750 && fmt.Sprint(sequence) == "[200x 1000x 5x]"
return scenarioResult{
Name: name, Passed: passed,
Summary: fmt.Sprintf("P=800固定脚本命中%s最终赔付=%d池后=%d", sequence, decision.PayoutCoins, decision.PoolAfterCoins),
Data: map[string]any{"sequence": sequence, "trace": decision.Trace},
}
}
func simulatePWBoundaries(config domain.StrategyConfig) scenarioResult {
const name = "P=W边界/候选穷尽"
config = baseProbabilityConfig(config)
equal, err := domain.DecideLuckyGiftStrategy(config, domain.StrategyState{PoolBalanceCoins: 2_000}, domain.StrategyInput{GiftPriceCoins: 10}, &scriptedRandom{indexes: []int64{950_000}, bounds: []int64{1_000_000}})
if err != nil {
return failedScenario(name, err)
}
exhausted, err := domain.DecideLuckyGiftStrategy(config, domain.StrategyState{PoolBalanceCoins: 49}, domain.StrategyInput{GiftPriceCoins: 10}, &scriptedRandom{indexes: []int64{999_999, 350_000, 349_999, 0}, bounds: []int64{1_000_000, 390_000, 350_000, 200_000}})
if err != nil {
return failedScenario(name, err)
}
passed := equal.PayoutCoins == 2_000 && equal.PoolAfterCoins == 0 && exhausted.PayoutCoins == 0 && exhausted.Trace.FinalReason == domain.StrategyReasonNoPayableTier && exhausted.PoolAfterCoins == 49
return scenarioResult{
Name: name, Passed: passed,
Summary: fmt.Sprintf("W=P赔付%d且池归零P=49时正奖候选穷尽安全回0池仍%d", equal.PayoutCoins, exhausted.PoolAfterCoins),
Data: map[string]any{"equality": equal.Trace, "exhaustion": exhausted.Trace},
}
}
func simulateFundSplitAndColdStart(config domain.StrategyConfig) scenarioResult {
const name = "拆账与冷启动"
split, err := domain.SplitLuckyGiftStrategyFunds(100, config)
if err != nil {
return failedScenario(name, err)
}
initial := domain.InitialLuckyGiftStrategyState(config)
small, err := domain.SplitLuckyGiftStrategyFunds(10, config)
if err != nil {
return failedScenario(name, err)
}
conserved := split.PublicPoolCoins+split.ProfitPoolCoins+split.AnchorReturnCoins == 100
passed := split.PublicPoolCoins == 98 && split.ProfitPoolCoins == 1 && split.AnchorReturnCoins == 1 && small.PublicPoolCoins == 9 && small.ProfitPoolCoins == 1 && small.AnchorReturnCoins == 0 && conserved && initial.PoolBalanceCoins == 0
return scenarioResult{
Name: name, Passed: passed,
Summary: fmt.Sprintf("100金币拆为%d/%d/%d10金币按公共池floor、余数归盈利拆为%d/%d/%d动态池冷启动=%d启动资金只能审计注资", split.PublicPoolCoins, split.ProfitPoolCoins, split.AnchorReturnCoins, small.PublicPoolCoins, small.ProfitPoolCoins, small.AnchorReturnCoins, initial.PoolBalanceCoins),
Data: map[string]any{
"split_100": split, "split_10_rounding": small,
"rounding_policy": "public=floor, anchor=floor, residue=profit",
"conserved": conserved, "cold_start_state": initial,
},
}
}
type batchSummary struct {
Requested int `json:"requested"`
Executed int `json:"executed"`
Positive int `json:"positive"`
PayoutCoins int64 `json:"payout_coins"`
PoolAfter int64 `json:"pool_after"`
TierCounts map[string]int `json:"tier_counts"`
}
func simulateBatchCounts(config domain.StrategyConfig, seed int64) scenarioResult {
const name = "1/5/6/99批量"
config.JackpotMechanism1Enabled = false
config.JackpotMechanism2Enabled = false
config.MilestoneSpendCoins = 0
config.DailyJackpotLimit = math.MaxInt64
counts := []int{1, 5, 6, 99}
results := make([]batchSummary, 0, len(counts))
passed := true
for _, count := range counts {
state := domain.StrategyState{}
random := domain.NewSeededStrategyRandom(seed + int64(count))
summary := batchSummary{Requested: count, TierCounts: map[string]int{}}
for index := 0; index < count; index++ {
decision, err := domain.DecideLuckyGiftStrategy(config, state, domain.StrategyInput{GiftPriceCoins: 100, PoolContributionCoins: 98}, random)
if err != nil {
return failedScenario(name, err)
}
state = decision.NextState
summary.Executed++
summary.TierCounts[decision.SelectedTier.ID]++
summary.PayoutCoins += decision.PayoutCoins
if decision.PayoutCoins > 0 {
summary.Positive++
}
passed = passed && decision.PoolAfterCoins >= 0
}
summary.PoolAfter = state.PoolBalanceCoins
passed = passed && summary.Executed == count
results = append(results, summary)
}
return scenarioResult{Name: name, Passed: passed, Summary: "每份礼物独立推进一次状态1/5/6/99均完整执行且任一中间池余额不负", Data: results}
}
func simulateSettlementWagerWindow() scenarioResult {
const name = "真实流水结算窗口"
const (
settlementWager = int64(99)
referencePrice = int64(10)
totalSpend = int64(100)
drawCount = int64(99)
)
type window struct {
Index int64 `json:"index"`
Paid int64 `json:"paid_draws"`
Wager int64 `json:"wager_coins"`
}
// 10 抽但只有 98 流水是专门构造的反例ceil(99/10)=10 的旧抽数算法会提前关窗,真实流水算法仍允许下一抽进入窗口 1。
windows := []window{{Index: 1, Paid: 10, Wager: 98}}
drawWindows := make([]int64, 0, drawCount)
for index := int64(1); index <= drawCount; index++ {
current := &windows[len(windows)-1]
if current.Wager >= settlementWager {
windows = append(windows, window{Index: current.Index + 1})
current = &windows[len(windows)-1]
}
unitSpend := totalSpend / drawCount
if index <= totalSpend%drawCount {
unitSpend++
}
current.Paid++
current.Wager += unitSpend
drawWindows = append(drawWindows, current.Index)
}
firstCount, secondCount := 0, 0
for _, index := range drawWindows {
if index == 1 {
firstCount++
} else if index == 2 {
secondCount++
}
}
passed := len(windows) == 2 && windows[0].Paid == 11 && windows[0].Wager == 100 && windows[1].Paid == 98 && windows[1].Wager == 98 && firstCount == 1 && secondCount == 98
return scenarioResult{
Name: name, Passed: passed,
Summary: fmt.Sprintf("阈值99、参考价10、预置10抽/98流水99份共100金币后窗口流水=%d/%d子抽归属=%d/%d", windows[0].Wager, windows[1].Wager, firstCount, secondCount),
Data: map[string]any{
"settlement_window_wager": settlementWager,
"reference_price": referencePrice, "legacy_derived_draw_threshold": int64(math.Ceil(float64(settlementWager) / float64(referencePrice))),
"windows": windows, "draws_in_window_1": firstCount, "draws_in_window_2": secondCount,
},
}
}
func simulateWaterBoundaries(config domain.StrategyConfig) scenarioResult {
const name = "低中高水位边界"
config.JackpotMechanism1Enabled = false
config.JackpotMechanism2Enabled = false
type waterPoint struct {
Pool int64 `json:"pool"`
FiveWeight int64 `json:"five_x_weight_ppm"`
Factors []string `json:"factors"`
}
points := make([]waterPoint, 0, 4)
for _, pool := range []int64{9_999_999, 10_000_000, 20_000_000, 20_000_001} {
_, traces, err := domain.PreviewLuckyGiftStrategyWeights(config, domain.StrategyState{PoolBalanceCoins: pool}, domain.StrategyInput{GiftPriceCoins: 100})
if err != nil {
return failedScenario(name, err)
}
trace := strategyWeightTrace(traces, "5x")
factors := make([]string, 0, len(trace.Factors))
for _, factor := range trace.Factors {
factors = append(factors, factor.Name)
}
points = append(points, waterPoint{Pool: pool, FiveWeight: trace.AdjustedWeightPPM, Factors: factors})
}
passed := points[0].FiveWeight == 140_000 && points[1].FiveWeight == 200_000 && points[2].FiveWeight == 200_000 && points[3].FiveWeight == 260_000
return scenarioResult{Name: name, Passed: passed, Summary: "<1000万应用0.7等于1000万/2000万均为中水位>2000万应用1.3", Data: points}
}
func simulateRechargeWindowBoundaries(config domain.StrategyConfig) scenarioResult {
const name = "充值0/299999/300000ms"
config.JackpotMechanism1Enabled = false
config.JackpotMechanism2Enabled = false
const now = int64(1_000_000)
type rechargePoint struct {
AgeMS int64 `json:"age_ms"`
FiveWeight int64 `json:"five_x_weight_ppm"`
Boosted bool `json:"boosted"`
}
points := make([]rechargePoint, 0, 3)
for _, age := range []int64{0, 299_999, 300_000} {
input := domain.StrategyInput{GiftPriceCoins: 100, NowMS: now, HasRechargeFact: true, LastRechargeAtMS: now - age}
_, traces, err := domain.PreviewLuckyGiftStrategyWeights(config, domain.StrategyState{PoolBalanceCoins: 15_000_000}, input)
if err != nil {
return failedScenario(name, err)
}
trace := strategyWeightTrace(traces, "5x")
boosted := false
for _, factor := range trace.Factors {
boosted = boosted || factor.Name == "recent_recharge"
}
points = append(points, rechargePoint{AgeMS: age, FiveWeight: trace.AdjustedWeightPPM, Boosted: boosted})
}
passed := points[0].Boosted && points[1].Boosted && !points[2].Boosted && points[0].FiveWeight == 220_000 && points[2].FiveWeight == 200_000
return scenarioResult{Name: name, Passed: passed, Summary: "充值加成严格使用[0,300000ms)0和299999生效300000失效", Data: points}
}
func simulateProbabilityMonteCarlo(config domain.StrategyConfig, seed int64, draws int) scenarioResult {
const name = "概率Monte Carlo"
config = baseProbabilityConfig(config)
config.MissProtectionZeroDraws = math.MaxInt64
state := domain.StrategyState{PoolBalanceCoins: 1_000_000_000_000_000}
random := domain.NewSeededStrategyRandom(seed)
counts := map[string]int{}
var multiplierSum float64
for index := 0; index < draws; index++ {
decision, err := domain.DecideLuckyGiftStrategy(config, state, domain.StrategyInput{GiftPriceCoins: 10}, random)
if err != nil {
return failedScenario(name, err)
}
state = decision.NextState
counts[decision.SelectedTier.ID]++
multiplierSum += float64(decision.SelectedTier.MultiplierPPM) / float64(domain.StrategyPPMScale)
}
expected := map[string]float64{"0x": 0.60, "5x": 0.20, "50x": 0.15, "200x": 0.04, "500x": 0, "1000x": 0.01}
empirical := map[string]float64{}
passed := true
for tier, probability := range expected {
empirical[tier] = float64(counts[tier]) / float64(draws)
// Five standard deviations makes the assertion scale with -draws instead
// of baking a tolerance that is too strict for a quick local smoke run.
tolerance := 5*math.Sqrt(probability*(1-probability)/float64(draws)) + 1/float64(draws)
passed = passed && math.Abs(empirical[tier]-probability) <= tolerance
}
empiricalEV := multiplierSum / float64(draws)
const multiplierVariance = 11_277.75 // E[X^2]=11980, E[X]^2=702.25.
evTolerance := 5 * math.Sqrt(multiplierVariance/float64(draws))
passed = passed && math.Abs(empiricalEV-26.5) <= evTolerance
return scenarioResult{
Name: name, Passed: passed,
Summary: fmt.Sprintf("%d抽经验EV=%.4fx只验证图示概率采样不声称通过98%%资金RTP", draws, empiricalEV),
Data: map[string]any{"counts": counts, "expected_probability": expected, "empirical_probability": empirical, "empirical_ev_multiplier": empiricalEV},
}
}
func simulateFundedRTPLongRun(config domain.StrategyConfig, seed int64, draws int) scenarioResult {
const name = "RTP长跑"
config.JackpotMechanism1Enabled = false
config.JackpotMechanism2Enabled = false
config.DailyJackpotLimit = math.MaxInt64
config.MilestoneSpendCoins = 0
state := domain.StrategyState{}
random := domain.NewSeededStrategyRandom(seed)
var contributed, payout int64
for index := 0; index < draws; index++ {
decision, err := domain.DecideLuckyGiftStrategy(config, state, domain.StrategyInput{GiftPriceCoins: 100, PoolContributionCoins: 98}, random)
if err != nil {
return failedScenario(name, err)
}
state = decision.NextState
contributed += 98
payout += decision.PayoutCoins
if state.PoolBalanceCoins < 0 {
return scenarioResult{Name: name, Passed: false, Summary: fmt.Sprintf("第%d抽资金为负", index+1)}
}
}
rtpPercent := float64(payout) / float64(draws*100) * 100
conserved := contributed-payout == state.PoolBalanceCoins
passed := conserved && payout <= contributed && rtpPercent <= 98
return scenarioResult{
Name: name, Passed: passed,
Summary: fmt.Sprintf("%d抽实付RTP=%.4f%%,投入=%d赔付=%d池余=%d", draws, rtpPercent, contributed, payout, state.PoolBalanceCoins),
Data: map[string]any{"contributed": contributed, "payout": payout, "pool_after": state.PoolBalanceCoins, "rtp_percent": rtpPercent, "fund_conserved": conserved},
}
}
func strategyWeightTrace(traces []domain.StrategyWeightTrace, tierID string) domain.StrategyWeightTrace {
for _, trace := range traces {
if trace.TierID == tierID {
return trace
}
}
return domain.StrategyWeightTrace{}
}
type mechanismOneRow struct {
GlobalPass bool `json:"global_pass"`
UserDayPass bool `json:"user_day_pass"`
User72HourPass bool `json:"user_72h_pass"`
Triggered bool `json:"triggered"`
SelectedTier string `json:"selected_tier"`
}
func simulateMechanismOneTruthTable(config domain.StrategyConfig, seed int64) scenarioResult {
const name = "大奖机制1真值表"
config = baseProbabilityConfig(config)
config.JackpotMechanism1Enabled = true
config.DailyJackpotLimit = 5
rows := make([]mechanismOneRow, 0, 8)
passed := true
rowIndex := int64(0)
for mask := 0; mask < 8; mask++ {
globalPass := mask&1 != 0
dayPass := mask&2 != 0
hour72Pass := mask&4 != 0
state := domain.StrategyState{
PoolBalanceCoins: 20_000,
GlobalRTP: rtpForTruth(globalPass, 98),
UserDayRTP: rtpForTruth(dayPass, 96),
User72HourRTP: rtpForTruth(hour72Pass, 96),
}
decision, err := domain.DecideLuckyGiftStrategy(config, state, domain.StrategyInput{GiftPriceCoins: 10}, domain.NewSeededStrategyRandom(seed+rowIndex))
if err != nil {
return failedScenario(name, err)
}
triggered := decision.JackpotMechanism == domain.StrategyJackpotMechanismRTPCompensation
want := globalPass && dayPass && hour72Pass
passed = passed && triggered == want
rows = append(rows, mechanismOneRow{GlobalPass: globalPass, UserDayPass: dayPass, User72HourPass: hour72Pass, Triggered: triggered, SelectedTier: decision.SelectedTier.ID})
rowIndex++
}
// The denominator-zero row is separate from boolean “ratio above threshold” so
// the report proves an empty window never qualifies as an apparent 0% RTP.
zeroState := domain.StrategyState{
PoolBalanceCoins: 20_000, GlobalRTP: domain.StrategyRTP{},
UserDayRTP: domain.StrategyRTP{WagerCoins: 100, PayoutCoins: 96},
User72HourRTP: domain.StrategyRTP{WagerCoins: 100, PayoutCoins: 96},
}
zeroDecision, err := domain.DecideLuckyGiftStrategy(config, zeroState, domain.StrategyInput{GiftPriceCoins: 10}, domain.NewSeededStrategyRandom(seed+99))
if err != nil {
return failedScenario(name, err)
}
zeroBlocked := zeroDecision.JackpotMechanism != domain.StrategyJackpotMechanismRTPCompensation
passed = passed && zeroBlocked
return scenarioResult{
Name: name, Passed: passed,
Summary: "global<=98%、用户日<=96%、用户72h<=96%三项全真才触发任一分母为0明确阻断",
Data: map[string]any{"truth_table": rows, "denominator_zero_blocked": zeroBlocked, "denominator_zero_conditions": zeroDecision.Trace.Conditions},
}
}
func rtpForTruth(pass bool, limitPercent int64) domain.StrategyRTP {
payout := limitPercent
if !pass {
payout++
}
return domain.StrategyRTP{WagerCoins: 100, PayoutCoins: payout}
}
func simulateMechanismTwo(config domain.StrategyConfig, seed int64) scenarioResult {
const name = "机制2里程碑/池不足"
config = baseProbabilityConfig(config)
config.JackpotMechanism2Enabled = true
// 模拟只用一个便于验证跨档的金币值;生产值必须来自当前不可变规则版本。
config.MilestoneSpendCoins = 50
config.DailyJackpotLimit = 5
input := domain.StrategyInput{GiftPriceCoins: 10}
blocked, err := domain.DecideLuckyGiftStrategy(config, domain.StrategyState{PoolBalanceCoins: 49, PendingMilestoneTokens: 1}, input, domain.NewSeededStrategyRandom(seed))
if err != nil {
return failedScenario(name, err)
}
payableState := blocked.NextState
payableState.PoolBalanceCoins = 20_000
paid, err := domain.DecideLuckyGiftStrategy(config, payableState, input, domain.NewSeededStrategyRandom(seed+1))
if err != nil {
return failedScenario(name, err)
}
crossed, err := domain.DecideLuckyGiftStrategy(config, domain.StrategyState{PoolBalanceCoins: 1_000, UserDaySpendCoins: 40}, input, domain.NewSeededStrategyRandom(seed+2))
if err != nil {
return failedScenario(name, err)
}
passed := blocked.NextState.PendingMilestoneTokens == 1 && blocked.Trace.MilestoneTokenRetained && paid.ConsumedMilestoneToken && paid.NextState.PendingMilestoneTokens == 0 && crossed.NextState.PendingMilestoneTokens == 1 && !crossed.ConsumedMilestoneToken
return scenarioResult{
Name: name, Passed: passed,
Summary: fmt.Sprintf("49池余额时资格保留=%v补池后命中%s并消费40→50本抽新增token=%d供下一抽", blocked.Trace.MilestoneTokenRetained, paid.SelectedTier.ID, crossed.Trace.MilestoneTokensEarned),
Data: map[string]any{"milestone_crossings_49_to_101": domain.MilestoneTokensEarned(49, 101, 50), "blocked": blocked, "paid": paid, "earned_for_next_draw": crossed},
}
}
func simulateJackpotSet(config domain.StrategyConfig, seed int64) scenarioResult {
const name = "大奖集合"
config = baseProbabilityConfig(config)
config.JackpotMechanism2Enabled = true
config.DailyJackpotLimit = 5
all := map[string]int{}
for index := int64(0); index < 120; index++ {
decision, err := domain.DecideLuckyGiftStrategy(config, domain.StrategyState{PoolBalanceCoins: 10_000, PendingMilestoneTokens: 1}, domain.StrategyInput{GiftPriceCoins: 10}, domain.NewSeededStrategyRandom(seed+index))
if err != nil {
return failedScenario(name, err)
}
all[decision.SelectedTier.ID]++
}
limited := map[string]int{}
for index := int64(0); index < 40; index++ {
decision, err := domain.DecideLuckyGiftStrategy(config, domain.StrategyState{PoolBalanceCoins: 4_999, PendingMilestoneTokens: 1}, domain.StrategyInput{GiftPriceCoins: 10}, domain.NewSeededStrategyRandom(seed+1_000+index))
if err != nil {
return failedScenario(name, err)
}
limited[decision.SelectedTier.ID]++
}
_, has200 := all["200x"]
_, has500 := all["500x"]
_, has1000 := all["1000x"]
passed := has200 && has500 && has1000 && len(limited) == 1 && limited["200x"] == 40
return scenarioResult{
Name: name, Passed: passed,
Summary: fmt.Sprintf("池足时大奖集合覆盖200/500/1000x=%vP=4999时只可能200x=%v", has200 && has500 && has1000, limited),
Data: map[string]any{"pool_10000_counts": all, "pool_4999_counts": limited},
}
}
func simulateDailyJackpotLimit(config domain.StrategyConfig, seed int64) scenarioResult {
const name = "日5次上限"
config = baseProbabilityConfig(config)
config.JackpotMechanism2Enabled = true
config.DailyJackpotLimit = 5
state := domain.StrategyState{PoolBalanceCoins: 1_000_000}
for index := 0; index < 5; index++ {
state.PendingMilestoneTokens = 1
decision, err := domain.DecideLuckyGiftStrategy(config, state, domain.StrategyInput{GiftPriceCoins: 10}, domain.NewSeededStrategyRandom(seed+int64(index)))
if err != nil {
return failedScenario(name, err)
}
state = decision.NextState
if !decision.Jackpot {
return scenarioResult{Name: name, Passed: false, Summary: fmt.Sprintf("第%d次未按资格出大奖", index+1)}
}
}
state.PendingMilestoneTokens = 1
// Force ordinary 200x after the special path is blocked, then force 0x on the
// retry. This proves the daily ceiling also constrains accidental base jackpots.
sixth, err := domain.DecideLuckyGiftStrategy(config, state, domain.StrategyInput{GiftPriceCoins: 10}, &scriptedRandom{indexes: []int64{950_000, 0}, bounds: []int64{1_000_000, 960_000}})
if err != nil {
return failedScenario(name, err)
}
passed := state.UserDailyJackpotWins == 5 && !sixth.Jackpot && sixth.NextState.UserDailyJackpotWins == 5 && sixth.NextState.PendingMilestoneTokens == 1
return scenarioResult{
Name: name, Passed: passed,
Summary: fmt.Sprintf("前5次大奖计数=%d第6次特殊与基础大奖均被硬阻断且资格保留", state.UserDailyJackpotWins),
Data: map[string]any{"sixth": sixth},
}
}
func simulateSixRiskCapacities(config domain.StrategyConfig) scenarioResult {
const name = "六维risk capacity代表边界"
config = baseProbabilityConfig(config)
config.Tiers = []domain.StrategyTier{
{ID: "0x", MultiplierPPM: 0, BaseWeightPPM: 0, Enabled: true},
{ID: "5x", MultiplierPPM: 5_000_000, BaseWeightPPM: domain.StrategyPPMScale, Enabled: true},
}
type dimension struct {
name string
set func(*domain.StrategyRiskCapacity, int64)
}
dimensions := []dimension{
{name: "single_draw", set: func(c *domain.StrategyRiskCapacity, v int64) { c.SingleDrawCoins = v }},
{name: "user_hour", set: func(c *domain.StrategyRiskCapacity, v int64) { c.UserHourCoins = v }},
{name: "user_day", set: func(c *domain.StrategyRiskCapacity, v int64) { c.UserDayCoins = v }},
{name: "device_day", set: func(c *domain.StrategyRiskCapacity, v int64) { c.DeviceDayCoins = v }},
{name: "room_hour", set: func(c *domain.StrategyRiskCapacity, v int64) { c.RoomHourCoins = v }},
{name: "anchor_day", set: func(c *domain.StrategyRiskCapacity, v int64) { c.AnchorDayCoins = v }},
}
rows := map[string]map[string]any{}
passed := true
for _, item := range dimensions {
capacity := domain.StrategyRiskCapacity{Enabled: true, SingleDrawCoins: 1_000, UserHourCoins: 1_000, UserDayCoins: 1_000, DeviceDayCoins: 1_000, RoomHourCoins: 1_000, AnchorDayCoins: 1_000}
item.set(&capacity, 50)
allowed, err := domain.DecideLuckyGiftStrategy(config, domain.StrategyState{PoolBalanceCoins: 50}, domain.StrategyInput{GiftPriceCoins: 10, RiskCapacity: capacity}, domain.NewSeededStrategyRandom(1))
if err != nil {
return failedScenario(name, err)
}
item.set(&capacity, 49)
blocked, err := domain.DecideLuckyGiftStrategy(config, domain.StrategyState{PoolBalanceCoins: 50}, domain.StrategyInput{GiftPriceCoins: 10, RiskCapacity: capacity}, domain.NewSeededStrategyRandom(1))
if err != nil {
return failedScenario(name, err)
}
rowPass := allowed.PayoutCoins == 50 && blocked.PayoutCoins == 0 && blocked.PoolAfterCoins == 50
passed = passed && rowPass
rows[item.name] = map[string]any{"equal_capacity_payout": allowed.PayoutCoins, "below_capacity_payout": blocked.PayoutCoins, "pass": rowPass}
}
return scenarioResult{Name: name, Passed: passed, Summary: "单次/用户小时/用户日/设备日/房间小时/主播日六维均验证W=capacity可赔、W=capacity+1硬阻断", Data: rows}
}
func simulateRechargeStages(config domain.StrategyConfig) scenarioResult {
const name = "充值分层"
type row struct {
Recharge7D int64 `json:"recharge_7d"`
Recharge30D int64 `json:"recharge_30d"`
Stage string `json:"stage"`
}
inputs := [][2]int64{
{99, 499}, // 非零但两维低于 normal。
{100, 499}, // 7 日等于 normal30 日未达,仍为 novice。
{99, 500}, // 30 日等于 normal7 日未达,仍为 novice。
{100, 500}, // 两维等于 normal进入 normal。
{500, 2_000},
}
wants := []string{domain.StageNovice, domain.StageNovice, domain.StageNovice, domain.StageNormal, domain.StageAdvanced}
rows := make([]row, 0, len(inputs))
passed := true
for index, input := range inputs {
stage := domain.SelectLuckyGiftRechargeStage(config, input[0], input[1])
passed = passed && stage == wants[index]
rows = append(rows, row{Recharge7D: input[0], Recharge30D: input[1], Stage: stage})
}
return scenarioResult{Name: name, Passed: passed, Summary: "normal/advanced同时达到7日与30日下限才提档任一维低于normal即使非零也是novice", Data: map[string]any{"thresholds": config.RechargeStages, "boundary_rows": rows}}
}
func simulateCombinationPriority(config domain.StrategyConfig, seed int64) scenarioResult {
const name = "组合优先级"
config = baseProbabilityConfig(config)
config.LowWaterThresholdCoins = 20_000
config.HighWaterThresholdCoins = 30_000
config.LowWaterFactorPPM = 700_000
config.HighWaterFactorPPM = 1_300_000
config.RechargeFactorPPM = 1_100_000
config.JackpotMechanism1Enabled = true
config.JackpotMechanism2Enabled = true
config.DailyJackpotLimit = 5
config.MilestoneSpendCoins = 50
state := domain.StrategyState{
PoolBalanceCoins: 10_000, ConsecutiveZeroDraws: 5, PendingMilestoneTokens: 1,
GlobalRTP: domain.StrategyRTP{WagerCoins: 100, PayoutCoins: 98},
UserDayRTP: domain.StrategyRTP{WagerCoins: 100, PayoutCoins: 96},
User72HourRTP: domain.StrategyRTP{WagerCoins: 100, PayoutCoins: 96},
}
input := domain.StrategyInput{
GiftPriceCoins: 10, NowMS: 1_000_000, HasRechargeFact: true,
LastRechargeAtMS: 700_001, Recharge7DCoins: 500, Recharge30DCoins: 2_000,
}
priority, err := domain.DecideLuckyGiftStrategy(config, state, input, domain.NewSeededStrategyRandom(seed))
if err != nil {
return failedScenario(name, err)
}
// When both jackpot mechanisms are eligible, the persisted token is contractual
// state and therefore wins before RTP compensation or sixth-draw protection.
priorityPass := priority.JackpotMechanism == domain.StrategyJackpotMechanismMilestone && priority.ConsumedMilestoneToken && priority.Stage == domain.StageAdvanced
blockedState := state
blockedState.PoolBalanceCoins = 50
blocked, err := domain.DecideLuckyGiftStrategy(config, blockedState, input, domain.NewSeededStrategyRandom(seed+1))
if err != nil {
return failedScenario(name, err)
}
// Neither special mechanism may overdraw the pool. The token survives and the
// ordinary sixth-draw protection may pay only the affordable 5x boundary.
blockedPass := blocked.Trace.MilestoneTokenRetained && blocked.NextState.PendingMilestoneTokens == 1 && blocked.PayoutCoins == 50 && blocked.PoolAfterCoins == 0
return scenarioResult{
Name: name, Passed: priorityPass && blockedPass,
Summary: fmt.Sprintf("优先级=持久token>%s>普通P/W/第6抽特殊档池不足时资格保留并仅赔可支付%s", domain.StrategyJackpotMechanismRTPCompensation, blocked.SelectedTier.ID),
Data: map[string]any{"all_eligible": priority, "special_pool_blocked": blocked},
}
}
// simulationStore models the repository contract that the pure kernel expects:
// command idempotency and state locking wrap the draw in one critical section. The
// simulator does not claim an in-memory mutex is the production implementation; it
// verifies the state-machine invariants a row lock/CAS transaction must preserve.
type simulationStore struct {
mu sync.Mutex
config domain.StrategyConfig
state domain.StrategyState
random domain.StrategyRandomSource
seen map[string]domain.StrategyDecision
transitions int
}
func newSimulationStore(config domain.StrategyConfig, state domain.StrategyState, seed int64) *simulationStore {
return &simulationStore{config: config, state: state, random: domain.NewSeededStrategyRandom(seed), seen: map[string]domain.StrategyDecision{}}
}
func (s *simulationStore) execute(commandID string, input domain.StrategyInput) (domain.StrategyDecision, error) {
s.mu.Lock()
defer s.mu.Unlock()
if existing, ok := s.seen[commandID]; ok {
return existing, nil
}
decision, err := domain.DecideLuckyGiftStrategy(s.config, s.state, input, s.random)
if err != nil {
return domain.StrategyDecision{}, err
}
s.state = decision.NextState
s.seen[commandID] = decision
s.transitions++
return decision, nil
}
func simulateConcurrencyAndIdempotency(config domain.StrategyConfig, seed int64) scenarioResult {
const name = "并发/幂等模型"
config.JackpotMechanism1Enabled = false
config.JackpotMechanism2Enabled = false
config.DailyJackpotLimit = math.MaxInt64
config.MilestoneSpendCoins = 0
input := domain.StrategyInput{GiftPriceCoins: 100, PoolContributionCoins: 98}
duplicateStore := newSimulationStore(config, domain.StrategyState{}, seed)
duplicateErrors := runConcurrentCommands(duplicateStore, 32, func(int) string { return "same-command" }, input)
duplicatePass := len(duplicateErrors) == 0 && duplicateStore.transitions == 1 && duplicateStore.state.Version == 1 && len(duplicateStore.seen) == 1 && duplicateStore.state.PoolBalanceCoins >= 0
uniqueStore := newSimulationStore(config, domain.StrategyState{}, seed+1)
uniqueErrors := runConcurrentCommands(uniqueStore, 100, func(index int) string { return fmt.Sprintf("command-%03d", index) }, input)
uniquePass := len(uniqueErrors) == 0 && uniqueStore.transitions == 100 && uniqueStore.state.Version == 100 && len(uniqueStore.seen) == 100 && uniqueStore.state.PoolBalanceCoins >= 0
return scenarioResult{
Name: name, Passed: duplicatePass && uniquePass,
Summary: fmt.Sprintf("32个同幂等键仅1次状态迁移100个并发唯一键串行化为100个版本且池余=%d", uniqueStore.state.PoolBalanceCoins),
Data: map[string]any{
"duplicate": map[string]any{"requests": 32, "transitions": duplicateStore.transitions, "version": duplicateStore.state.Version, "errors": duplicateErrors},
"unique": map[string]any{"requests": 100, "transitions": uniqueStore.transitions, "version": uniqueStore.state.Version, "pool_after": uniqueStore.state.PoolBalanceCoins, "errors": uniqueErrors},
},
}
}
func runConcurrentCommands(store *simulationStore, count int, commandID func(int) string, input domain.StrategyInput) []string {
var wait sync.WaitGroup
errorsCh := make(chan string, count)
for index := 0; index < count; index++ {
wait.Add(1)
go func(index int) {
defer wait.Done()
if _, err := store.execute(commandID(index), input); err != nil {
errorsCh <- err.Error()
}
}(index)
}
wait.Wait()
close(errorsCh)
errorsFound := make([]string, 0, len(errorsCh))
for err := range errorsCh {
errorsFound = append(errorsFound, err)
}
sort.Strings(errorsFound)
return errorsFound
}
type groupLongRunSummary struct {
ExpectedStage string `json:"expected_stage"`
ObservedStage string `json:"observed_stage"`
Draws int `json:"draws"`
TierCounts map[string]int `json:"tier_counts"`
PayoutCoins int64 `json:"payout_coins"`
PoolAfter int64 `json:"pool_after"`
RTPPercent float64 `json:"rtp_percent"`
}
func simulateLongRunGroups(config domain.StrategyConfig, seed int64, draws int) scenarioResult {
const name = "长跑分群"
config.JackpotMechanism1Enabled = false
config.JackpotMechanism2Enabled = false
config.DailyJackpotLimit = math.MaxInt64
config.MilestoneSpendCoins = 0
// This grouping run uses a production-safe table (stage EV 85%/97.5%/98%),
// not the image's 2650% probability snapshot. That keeps distribution changes
// visible instead of letting P/W drain every group into the same 5x-only cycle.
config.LowWaterThresholdCoins = 0
config.HighWaterThresholdCoins = math.MaxInt64
config.LowWaterFactorPPM = domain.StrategyPPMScale
config.HighWaterFactorPPM = domain.StrategyPPMScale
config.RechargeFactorPPM = domain.StrategyPPMScale
config.MissProtectionZeroDraws = math.MaxInt64
config.Tiers = []domain.StrategyTier{
{ID: "0x", MultiplierPPM: 0, Enabled: true},
{ID: "0.5x", MultiplierPPM: 500_000, Enabled: true, StageWeightPPM: map[string]int64{domain.StageNovice: 100_000, domain.StageNormal: 50_000, domain.StageAdvanced: 40_000}},
{ID: "1x", MultiplierPPM: 1_000_000, Enabled: true, StageWeightPPM: map[string]int64{domain.StageNovice: 800_000, domain.StageNormal: 850_000, domain.StageAdvanced: 840_000}},
{ID: "2x", MultiplierPPM: 2_000_000, Enabled: true, StageWeightPPM: map[string]int64{domain.StageNovice: 0, domain.StageNormal: 50_000, domain.StageAdvanced: 60_000}},
}
groups := []struct {
stage string
recharge7D int64
recharge30D int64
}{
{stage: domain.StageNovice, recharge7D: 99, recharge30D: 499},
{stage: domain.StageNormal, recharge7D: 100, recharge30D: 500},
{stage: domain.StageAdvanced, recharge7D: 500, recharge30D: 2_000},
}
results := make([]groupLongRunSummary, 0, len(groups))
passed := true
for groupIndex, group := range groups {
state := domain.StrategyState{}
random := domain.NewSeededStrategyRandom(seed + int64(groupIndex))
summary := groupLongRunSummary{ExpectedStage: group.stage, Draws: draws, TierCounts: map[string]int{}}
for index := 0; index < draws; index++ {
decision, err := domain.DecideLuckyGiftStrategy(config, state, domain.StrategyInput{
GiftPriceCoins: 100, PoolContributionCoins: 98,
Recharge7DCoins: group.recharge7D, Recharge30DCoins: group.recharge30D,
}, random)
if err != nil {
return failedScenario(name, err)
}
state = decision.NextState
summary.ObservedStage = decision.Stage
summary.TierCounts[decision.SelectedTier.ID]++
summary.PayoutCoins += decision.PayoutCoins
if state.PoolBalanceCoins < 0 {
passed = false
}
}
summary.PoolAfter = state.PoolBalanceCoins
summary.RTPPercent = float64(summary.PayoutCoins) / float64(draws*100) * 100
passed = passed && summary.ObservedStage == group.stage && summary.RTPPercent <= 98 && int64(draws*98)-summary.PayoutCoins == summary.PoolAfter
results = append(results, summary)
}
return scenarioResult{Name: name, Passed: passed, Summary: fmt.Sprintf("novice/normal/advanced各%d抽分层权重不同但三组资金RTP均<=98%%且守恒", draws), Data: results}
}

View File

@ -78,8 +78,11 @@ CREATE TABLE IF NOT EXISTS lucky_gift_rule_versions (
pool_id VARCHAR(96) NOT NULL COMMENT '幸运礼物奖池 ID', pool_id VARCHAR(96) NOT NULL COMMENT '幸运礼物奖池 ID',
rule_version BIGINT NOT NULL COMMENT '不可变规则版本', rule_version BIGINT NOT NULL COMMENT '不可变规则版本',
enabled TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否启用', enabled TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否启用',
strategy_version VARCHAR(32) NOT NULL DEFAULT 'fixed_v2' COMMENT 'fixed_v2/dynamic_v3旧版本固定按 fixed_v2 解释',
target_rtp_ppm BIGINT NOT NULL COMMENT '基础返奖目标ppm', target_rtp_ppm BIGINT NOT NULL COMMENT '基础返奖目标ppm',
pool_rate_ppm BIGINT NOT NULL COMMENT '每笔扣费进入基础奖池比例ppm', pool_rate_ppm BIGINT NOT NULL COMMENT '每笔扣费进入基础奖池比例ppm',
profit_rate_ppm BIGINT NOT NULL DEFAULT 0 COMMENT '每笔扣费计入平台盈利比例ppm',
anchor_rate_ppm BIGINT NOT NULL DEFAULT 0 COMMENT '每笔扣费计入主播收益比例ppm',
settlement_window_wager BIGINT NOT NULL COMMENT 'RTP 结算窗口流水,单位金币', settlement_window_wager BIGINT NOT NULL COMMENT 'RTP 结算窗口流水,单位金币',
control_band_ppm BIGINT NOT NULL COMMENT '正常波动带ppm', control_band_ppm BIGINT NOT NULL COMMENT '正常波动带ppm',
gift_price_reference BIGINT NOT NULL COMMENT '配置参考礼物价格', gift_price_reference BIGINT NOT NULL COMMENT '配置参考礼物价格',
@ -88,6 +91,26 @@ CREATE TABLE IF NOT EXISTS lucky_gift_rule_versions (
effective_from_ms BIGINT NOT NULL COMMENT '生效时间UTC epoch ms', effective_from_ms BIGINT NOT NULL COMMENT '生效时间UTC epoch ms',
created_by_admin_id BIGINT NOT NULL DEFAULT 0 COMMENT '发布人', created_by_admin_id BIGINT NOT NULL DEFAULT 0 COMMENT '发布人',
created_at_ms BIGINT NOT NULL COMMENT '创建时间UTC epoch ms', created_at_ms BIGINT NOT NULL COMMENT '创建时间UTC epoch ms',
initial_pool_coins BIGINT NOT NULL DEFAULT 0 COMMENT 'dynamic_v3 奖池首次物化的初始注资金币',
loss_streak_guarantee BIGINT NOT NULL DEFAULT 0 COMMENT '连续未中奖后的保底触发抽数',
low_watermark_coins BIGINT NOT NULL DEFAULT 0 COMMENT '低水位阈值金币',
low_water_nonzero_factor_ppm BIGINT NOT NULL DEFAULT 0 COMMENT '低水位非零奖档概率因子ppm',
high_watermark_coins BIGINT NOT NULL DEFAULT 0 COMMENT '高水位阈值金币',
high_water_nonzero_factor_ppm BIGINT NOT NULL DEFAULT 0 COMMENT '高水位非零奖档概率因子ppm',
recharge_boost_window_ms BIGINT NOT NULL DEFAULT 0 COMMENT '最近充值概率加权窗口,毫秒',
recharge_boost_factor_ppm BIGINT NOT NULL DEFAULT 0 COMMENT '最近充值非零奖档概率因子ppm',
jackpot_multiplier_ppms JSON NOT NULL DEFAULT (JSON_ARRAY()) COMMENT '动态大奖倍率列表1x=1000000',
jackpot_global_rtp_max_ppm BIGINT NOT NULL DEFAULT 0 COMMENT '动态大奖全局 RTP 上限ppm',
jackpot_user_day_rtp_max_ppm BIGINT NOT NULL DEFAULT 0 COMMENT '动态大奖用户当日 RTP 上限ppm',
jackpot_user_72h_rtp_max_ppm BIGINT NOT NULL DEFAULT 0 COMMENT '动态大奖用户滚动 72 小时 RTP 上限ppm',
jackpot_spend_threshold_coins BIGINT NOT NULL DEFAULT 0 COMMENT '规则版本动态配置的用户 UTC 日累计消费大奖门槛金币',
max_jackpot_hits_per_user_day BIGINT NOT NULL DEFAULT 0 COMMENT '单用户 UTC 日动态大奖命中上限',
max_single_payout BIGINT NOT NULL DEFAULT 0 COMMENT '单次返奖金币上限',
user_hourly_payout_cap BIGINT NOT NULL DEFAULT 0 COMMENT '单用户 UTC 小时返奖上限',
user_daily_payout_cap BIGINT NOT NULL DEFAULT 0 COMMENT '单用户 UTC 日返奖上限',
device_daily_payout_cap BIGINT NOT NULL DEFAULT 0 COMMENT '单设备 UTC 日返奖上限',
room_hourly_payout_cap BIGINT NOT NULL DEFAULT 0 COMMENT '单房间 UTC 小时返奖上限',
anchor_daily_payout_cap BIGINT NOT NULL DEFAULT 0 COMMENT '单主播 UTC 日返奖上限',
PRIMARY KEY (app_code, pool_id, rule_version), PRIMARY KEY (app_code, pool_id, rule_version),
KEY idx_lucky_gift_rule_versions_latest (app_code, pool_id, rule_version), KEY idx_lucky_gift_rule_versions_latest (app_code, pool_id, rule_version),
KEY idx_lucky_gift_rule_versions_enabled (app_code, enabled, created_at_ms) KEY idx_lucky_gift_rule_versions_enabled (app_code, enabled, created_at_ms)
@ -98,6 +121,8 @@ CREATE TABLE IF NOT EXISTS lucky_gift_stage_tiers (
pool_id VARCHAR(96) NOT NULL COMMENT '幸运礼物奖池 ID', pool_id VARCHAR(96) NOT NULL COMMENT '幸运礼物奖池 ID',
rule_version BIGINT NOT NULL COMMENT '规则版本', rule_version BIGINT NOT NULL COMMENT '规则版本',
stage VARCHAR(32) NOT NULL COMMENT 'novice/normal/advanced', stage VARCHAR(32) NOT NULL COMMENT 'novice/normal/advanced',
min_recharge_7d_coins BIGINT NOT NULL DEFAULT 0 COMMENT 'normal/advanced 最近 7 日充值下限novice 的 0 仅为 sentinel',
min_recharge_30d_coins BIGINT NOT NULL DEFAULT 0 COMMENT 'normal/advanced 最近 30 日充值下限novice 的 0 仅为 sentinel',
tier_id VARCHAR(96) NOT NULL COMMENT '奖档 ID', tier_id VARCHAR(96) NOT NULL COMMENT '奖档 ID',
multiplier_ppm BIGINT NOT NULL COMMENT '倍率1x = 1000000', multiplier_ppm BIGINT NOT NULL COMMENT '倍率1x = 1000000',
base_weight_ppm BIGINT NOT NULL COMMENT '正常模式基础概率ppm', base_weight_ppm BIGINT NOT NULL COMMENT '正常模式基础概率ppm',
@ -110,10 +135,10 @@ CREATE TABLE IF NOT EXISTS lucky_gift_stage_tiers (
CREATE TABLE IF NOT EXISTS lucky_rtp_windows ( CREATE TABLE IF NOT EXISTS lucky_rtp_windows (
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码', app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码',
scope_type VARCHAR(32) NOT NULL COMMENT 'global 或 gift', scope_type VARCHAR(32) NOT NULL COMMENT 'global 或 gift',
scope_id VARCHAR(96) NOT NULL COMMENT 'global 或规则作用域', scope_id VARCHAR(128) NOT NULL COMMENT '奖池+不可变规则版本作用域',
window_index BIGINT NOT NULL COMMENT 'RTP 窗口序号', window_index BIGINT NOT NULL COMMENT 'RTP 窗口序号',
target_rtp_ppm INT NOT NULL COMMENT '窗口目标 RTP', target_rtp_ppm INT NOT NULL COMMENT '窗口目标 RTP',
control_window_draws BIGINT NOT NULL COMMENT '窗口抽数', control_window_draws BIGINT NOT NULL COMMENT 'fixed_v2窗口抽数dynamic_v3仅兼容展示滚窗读取规则真实流水阈值',
paid_draws BIGINT NOT NULL DEFAULT 0 COMMENT '窗口已抽次数', paid_draws BIGINT NOT NULL DEFAULT 0 COMMENT '窗口已抽次数',
wager_coins BIGINT NOT NULL DEFAULT 0 COMMENT '窗口消耗金币', wager_coins BIGINT NOT NULL DEFAULT 0 COMMENT '窗口消耗金币',
target_payout_coins BIGINT NOT NULL COMMENT '窗口目标基础返奖', target_payout_coins BIGINT NOT NULL COMMENT '窗口目标基础返奖',
@ -134,11 +159,93 @@ CREATE TABLE IF NOT EXISTS lucky_pools (
reserve_floor BIGINT NOT NULL DEFAULT 0 COMMENT '安全水位', reserve_floor BIGINT NOT NULL DEFAULT 0 COMMENT '安全水位',
total_in BIGINT NOT NULL DEFAULT 0 COMMENT '累计入池', total_in BIGINT NOT NULL DEFAULT 0 COMMENT '累计入池',
total_out 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 累计初始注资金币,只在首次物化时增加',
created_at_ms BIGINT NOT NULL COMMENT '创建时间UTC epoch ms', created_at_ms BIGINT NOT NULL COMMENT '创建时间UTC epoch ms',
updated_at_ms BIGINT NOT NULL COMMENT '更新时间UTC epoch ms', updated_at_ms BIGINT NOT NULL COMMENT '更新时间UTC epoch ms',
PRIMARY KEY (app_code, scope_type, scope_id) PRIMARY KEY (app_code, scope_type, scope_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='幸运礼物单一基础奖池'; ) 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',
scope_id VARCHAR(255) NOT NULL COMMENT 'pool_id+风控主体 ID容纳96字符奖池与128字符设备',
window_type VARCHAR(32) NOT NULL COMMENT 'hour/day',
bucket_key VARCHAR(32) NOT NULL COMMENT 'UTC 窗口键',
payout_coins BIGINT NOT NULL DEFAULT 0 COMMENT '窗口内已支出可见奖励',
created_at_ms BIGINT NOT NULL COMMENT '创建时间UTC epoch ms',
updated_at_ms BIGINT NOT NULL COMMENT '更新时间UTC epoch ms',
PRIMARY KEY (app_code, scope_type, scope_id, window_type, bucket_key)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='幸运礼物多维风控窗口计数';
CREATE TABLE IF NOT EXISTS lucky_user_rtp_hour_buckets (
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码',
pool_id VARCHAR(96) NOT NULL COMMENT '幸运礼物奖池 ID',
user_id BIGINT NOT NULL COMMENT '用户 ID',
bucket_hour_ms BIGINT NOT NULL COMMENT 'UTC 小时开始时间epoch ms',
wager_coins BIGINT NOT NULL DEFAULT 0 COMMENT '小时内抽奖流水金币',
payout_coins BIGINT NOT NULL DEFAULT 0 COMMENT '小时内返奖金币',
created_at_ms BIGINT NOT NULL COMMENT '创建时间UTC epoch ms',
updated_at_ms BIGINT NOT NULL COMMENT '更新时间UTC epoch ms',
PRIMARY KEY (app_code, pool_id, user_id, bucket_hour_ms),
KEY idx_lucky_user_rtp_hour_retention (app_code, bucket_hour_ms)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户滚动72小时RTP完整小时聚合桶';
-- 小时桶负责完整小时,边界事件只用于精确补齐 [now-72h,now) 两端不足一小时的片段。
-- 这样热路径最多扫描 71 个小时桶和两个边界小时内的用户事件,不回扫全量抽奖事实。
CREATE TABLE IF NOT EXISTS lucky_user_rtp_boundary_events (
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码',
pool_id VARCHAR(96) NOT NULL COMMENT '幸运礼物奖池 ID',
user_id BIGINT NOT NULL COMMENT '用户 ID',
draw_id VARCHAR(128) NOT NULL COMMENT '单抽事实 ID用于幂等',
occurred_at_ms BIGINT NOT NULL COMMENT '扣费事实时间UTC epoch ms',
wager_coins BIGINT NOT NULL COMMENT '本抽流水金币',
payout_coins BIGINT NOT NULL COMMENT '本抽已承诺返奖负债',
created_at_ms BIGINT NOT NULL COMMENT '创建时间UTC epoch ms',
PRIMARY KEY (app_code, pool_id, user_id, draw_id),
KEY idx_lucky_user_rtp_boundary_range (app_code, pool_id, user_id, occurred_at_ms)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户严格滚动72小时RTP边界事件';
CREATE TABLE IF NOT EXISTS lucky_user_strategy_days (
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码',
pool_id VARCHAR(96) NOT NULL COMMENT '幸运礼物奖池 ID',
user_id BIGINT NOT NULL COMMENT '用户 ID',
stat_day DATE NOT NULL COMMENT 'UTC 自然日',
wager_coins BIGINT NOT NULL DEFAULT 0 COMMENT '当日抽奖流水金币',
payout_coins BIGINT NOT NULL DEFAULT 0 COMMENT '当日返奖金币',
spend_coins BIGINT NOT NULL DEFAULT 0 COMMENT '当日用于动态大奖资格的累计消费金币',
jackpot_hits BIGINT NOT NULL DEFAULT 0 COMMENT '当日动态大奖已命中次数',
created_at_ms BIGINT NOT NULL COMMENT '创建时间UTC epoch ms',
updated_at_ms BIGINT NOT NULL COMMENT '更新时间UTC epoch ms',
PRIMARY KEY (app_code, pool_id, user_id, stat_day),
KEY idx_lucky_user_strategy_days_retention (app_code, stat_day)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户 dynamic_v3 UTC 日级消费、RTP 与大奖资格状态';
CREATE TABLE IF NOT EXISTS lucky_user_states ( CREATE TABLE IF NOT EXISTS lucky_user_states (
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码', app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码',
user_id BIGINT NOT NULL COMMENT '用户 ID', user_id BIGINT NOT NULL COMMENT '用户 ID',
@ -147,11 +254,21 @@ CREATE TABLE IF NOT EXISTS lucky_user_states (
cumulative_wager_coins BIGINT NOT NULL DEFAULT 0 COMMENT '该用户在当前奖池累计消耗金币', cumulative_wager_coins BIGINT NOT NULL DEFAULT 0 COMMENT '该用户在当前奖池累计消耗金币',
equivalent_draws BIGINT NOT NULL DEFAULT 0 COMMENT '累计金币按规则参考价格折算后的等价抽数', equivalent_draws BIGINT NOT NULL DEFAULT 0 COMMENT '累计金币按规则参考价格折算后的等价抽数',
loss_streak BIGINT NOT NULL DEFAULT 0 COMMENT '连续未获得可见奖励次数,中奖后清零', loss_streak BIGINT NOT NULL DEFAULT 0 COMMENT '连续未获得可见奖励次数,中奖后清零',
pending_jackpot_tokens BIGINT NOT NULL DEFAULT 0 COMMENT '未消费大奖机会按UTC日消费产生资金不足时允许跨日保留',
created_at_ms BIGINT NOT NULL COMMENT '创建时间UTC epoch ms', created_at_ms BIGINT NOT NULL COMMENT '创建时间UTC epoch ms',
updated_at_ms BIGINT NOT NULL COMMENT '更新时间UTC epoch ms', updated_at_ms BIGINT NOT NULL COMMENT '更新时间UTC epoch ms',
PRIMARY KEY (app_code, user_id, gift_id) PRIMARY KEY (app_code, user_id, gift_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='幸运礼物用户体验池状态'; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='幸运礼物用户体验池状态';
-- draw 事实不存在时 SELECT ... FOR UPDATE 没有可锁记录;该锁行保证同一钱包 command_id 的
-- 首次并发请求串行执行,等待者在首事务提交后只回读原始结果,不重新随机或重复推进奖池。
CREATE TABLE IF NOT EXISTS lucky_gift_command_locks (
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码',
command_id VARCHAR(128) NOT NULL COMMENT '钱包已扣费命令幂等 ID',
created_at_ms BIGINT NOT NULL COMMENT '首次请求时间UTC epoch ms',
PRIMARY KEY (app_code, command_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='内部幸运礼物并发幂等锁行';
CREATE TABLE IF NOT EXISTS lucky_draw_records ( CREATE TABLE IF NOT EXISTS lucky_draw_records (
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码', app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码',
draw_id VARCHAR(128) NOT NULL COMMENT '抽奖 ID', draw_id VARCHAR(128) NOT NULL COMMENT '抽奖 ID',
@ -257,6 +374,13 @@ CREATE TABLE IF NOT EXISTS lucky_draw_pool_stat_cursors (
PRIMARY KEY (app_code, cursor_name) PRIMARY KEY (app_code, cursor_name)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='幸运礼物后台统计快照游标'; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='幸运礼物后台统计快照游标';
CREATE TABLE IF NOT EXISTS external_lucky_gift_request_locks (
app_code VARCHAR(32) NOT NULL COMMENT '外部 App 应用编码',
request_id VARCHAR(128) NOT NULL COMMENT '外部业务幂等 ID',
created_at_ms BIGINT NOT NULL COMMENT '首次请求时间UTC epoch ms',
PRIMARY KEY (app_code, request_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='外部幸运礼物并发幂等锁行';
CREATE TABLE IF NOT EXISTS external_lucky_gift_draws ( CREATE TABLE IF NOT EXISTS external_lucky_gift_draws (
app_code VARCHAR(32) NOT NULL COMMENT '外部 App 应用编码', app_code VARCHAR(32) NOT NULL COMMENT '外部 App 应用编码',
request_id VARCHAR(128) NOT NULL COMMENT '外部 App 业务幂等 ID', request_id VARCHAR(128) NOT NULL COMMENT '外部 App 业务幂等 ID',
@ -282,3 +406,28 @@ CREATE TABLE IF NOT EXISTS external_lucky_gift_draws (
KEY idx_external_lucky_pool (app_code, pool_id, created_at_ms), KEY idx_external_lucky_pool (app_code, pool_id, created_at_ms),
KEY idx_external_lucky_status (app_code, reward_status, created_at_ms) KEY idx_external_lucky_status (app_code, reward_status, created_at_ms)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='外部 App 幸运礼物抽奖事实表'; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='外部 App 幸运礼物抽奖事实表';
CREATE TABLE IF NOT EXISTS external_lucky_gift_draw_items (
app_code VARCHAR(32) NOT NULL COMMENT '外部 App 应用编码',
request_id VARCHAR(128) NOT NULL COMMENT '聚合请求幂等 ID',
item_index INT NOT NULL COMMENT '从1开始的顺序子抽序号',
draw_id VARCHAR(128) NOT NULL COMMENT '子抽事实 ID',
command_id VARCHAR(128) NOT NULL COMMENT '稳定子命令 ID',
external_user_id VARCHAR(128) NOT NULL COMMENT '外部 App 用户 ID',
pool_id VARCHAR(96) NOT NULL COMMENT '幸运礼物奖池 ID',
rule_version BIGINT NOT NULL COMMENT '不可变规则版本',
unit_amount BIGINT NOT NULL COMMENT '本抽实际金额',
reward_amount BIGINT NOT NULL COMMENT '本抽奖励金额',
selected_tier_id VARCHAR(96) NOT NULL COMMENT '本抽最终奖档',
multiplier_ppm BIGINT NOT NULL COMMENT '本抽倍率',
stage VARCHAR(32) NOT NULL COMMENT '充值分层缺可信外部充值事实时为novice',
candidate_snapshot_json JSON NOT NULL COMMENT '动态权重、删除重抽及大奖条件快照',
pool_snapshot_json JSON NOT NULL COMMENT '本抽资金拆分和池快照',
rtp_snapshot_json JSON NOT NULL COMMENT '本抽RTP快照',
reward_status VARCHAR(32) NOT NULL DEFAULT 'granted' COMMENT '外部调用方自行结算',
paid_at_ms BIGINT NOT NULL COMMENT '外部扣费时间UTC epoch ms',
created_at_ms BIGINT NOT NULL COMMENT '创建时间UTC epoch ms',
PRIMARY KEY (app_code, request_id, item_index),
UNIQUE KEY uk_external_lucky_item_draw (app_code, draw_id),
KEY idx_external_lucky_item_user (app_code, external_user_id, created_at_ms)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='外部dynamic_v3顺序子抽审计';

View File

@ -0,0 +1,226 @@
SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci;
USE hyapp_lucky_gift;
-- 每列独立探测,兼容“曾运行旧 002、未运行旧 002、004 执行到一半”三种线上状态;
-- strategy_version 的默认值固定为 fixed_v2确保已有不可变版本不会被动态规则重新解释。
SET @ddl := IF((SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'lucky_gift_rule_versions' AND COLUMN_NAME = 'strategy_version') = 0,
'ALTER TABLE lucky_gift_rule_versions ADD COLUMN strategy_version VARCHAR(32) NOT NULL DEFAULT ''fixed_v2'' COMMENT ''fixed_v2/dynamic_v3旧版本固定按 fixed_v2 解释'' AFTER enabled', '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_gift_rule_versions' AND COLUMN_NAME = 'profit_rate_ppm') = 0,
'ALTER TABLE lucky_gift_rule_versions ADD COLUMN profit_rate_ppm BIGINT NOT NULL DEFAULT 0 COMMENT ''平台盈利比例ppm'' AFTER pool_rate_ppm', '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_gift_rule_versions' AND COLUMN_NAME = 'anchor_rate_ppm') = 0,
'ALTER TABLE lucky_gift_rule_versions ADD COLUMN anchor_rate_ppm BIGINT NOT NULL DEFAULT 0 COMMENT ''主播收益比例ppm'' AFTER profit_rate_ppm', '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_gift_rule_versions' AND COLUMN_NAME = 'initial_pool_coins') = 0,
'ALTER TABLE lucky_gift_rule_versions ADD COLUMN initial_pool_coins BIGINT NOT NULL DEFAULT 0 COMMENT ''dynamic_v3 初始注资金币'' AFTER created_at_ms', '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_gift_rule_versions' AND COLUMN_NAME = 'loss_streak_guarantee') = 0,
'ALTER TABLE lucky_gift_rule_versions ADD COLUMN loss_streak_guarantee BIGINT NOT NULL DEFAULT 0 COMMENT ''连续未中奖保底抽数'' AFTER initial_pool_coins', '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_gift_rule_versions' AND COLUMN_NAME = 'low_watermark_coins') = 0,
'ALTER TABLE lucky_gift_rule_versions ADD COLUMN low_watermark_coins BIGINT NOT NULL DEFAULT 0 COMMENT ''低水位阈值金币'' AFTER loss_streak_guarantee', '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_gift_rule_versions' AND COLUMN_NAME = 'low_water_nonzero_factor_ppm') = 0,
'ALTER TABLE lucky_gift_rule_versions ADD COLUMN low_water_nonzero_factor_ppm BIGINT NOT NULL DEFAULT 0 COMMENT ''低水位非零概率因子ppm'' AFTER low_watermark_coins', '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_gift_rule_versions' AND COLUMN_NAME = 'high_watermark_coins') = 0,
'ALTER TABLE lucky_gift_rule_versions ADD COLUMN high_watermark_coins BIGINT NOT NULL DEFAULT 0 COMMENT ''高水位阈值金币'' AFTER low_water_nonzero_factor_ppm', '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_gift_rule_versions' AND COLUMN_NAME = 'high_water_nonzero_factor_ppm') = 0,
'ALTER TABLE lucky_gift_rule_versions ADD COLUMN high_water_nonzero_factor_ppm BIGINT NOT NULL DEFAULT 0 COMMENT ''高水位非零概率因子ppm'' AFTER high_watermark_coins', '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_gift_rule_versions' AND COLUMN_NAME = 'recharge_boost_window_ms') = 0,
'ALTER TABLE lucky_gift_rule_versions ADD COLUMN recharge_boost_window_ms BIGINT NOT NULL DEFAULT 0 COMMENT ''充值加权窗口毫秒'' AFTER high_water_nonzero_factor_ppm', '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_gift_rule_versions' AND COLUMN_NAME = 'recharge_boost_factor_ppm') = 0,
'ALTER TABLE lucky_gift_rule_versions ADD COLUMN recharge_boost_factor_ppm BIGINT NOT NULL DEFAULT 0 COMMENT ''充值加权因子ppm'' AFTER recharge_boost_window_ms', '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_gift_rule_versions' AND COLUMN_NAME = 'jackpot_multiplier_ppms') = 0,
'ALTER TABLE lucky_gift_rule_versions ADD COLUMN jackpot_multiplier_ppms JSON NOT NULL DEFAULT (JSON_ARRAY()) COMMENT ''动态大奖倍率列表'' AFTER recharge_boost_factor_ppm', '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_gift_rule_versions' AND COLUMN_NAME = 'jackpot_global_rtp_max_ppm') = 0,
'ALTER TABLE lucky_gift_rule_versions ADD COLUMN jackpot_global_rtp_max_ppm BIGINT NOT NULL DEFAULT 0 COMMENT ''全局大奖 RTP 上限'' AFTER jackpot_multiplier_ppms', '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_gift_rule_versions' AND COLUMN_NAME = 'jackpot_user_day_rtp_max_ppm') = 0,
'ALTER TABLE lucky_gift_rule_versions ADD COLUMN jackpot_user_day_rtp_max_ppm BIGINT NOT NULL DEFAULT 0 COMMENT ''用户当日大奖 RTP 上限'' AFTER jackpot_global_rtp_max_ppm', '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_gift_rule_versions' AND COLUMN_NAME = 'jackpot_user_72h_rtp_max_ppm') = 0,
'ALTER TABLE lucky_gift_rule_versions ADD COLUMN jackpot_user_72h_rtp_max_ppm BIGINT NOT NULL DEFAULT 0 COMMENT ''用户滚动72小时大奖 RTP 上限'' AFTER jackpot_user_day_rtp_max_ppm', '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_gift_rule_versions' AND COLUMN_NAME = 'jackpot_spend_threshold_coins') = 0,
'ALTER TABLE lucky_gift_rule_versions ADD COLUMN jackpot_spend_threshold_coins BIGINT NOT NULL DEFAULT 0 COMMENT ''规则版本动态配置的用户 UTC 日累计消费大奖门槛金币'' AFTER jackpot_user_72h_rtp_max_ppm', '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_gift_rule_versions' AND COLUMN_NAME = 'max_jackpot_hits_per_user_day') = 0,
'ALTER TABLE lucky_gift_rule_versions ADD COLUMN max_jackpot_hits_per_user_day BIGINT NOT NULL DEFAULT 0 COMMENT ''用户UTC日大奖命中上限'' AFTER jackpot_spend_threshold_coins', '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_gift_rule_versions' AND COLUMN_NAME = 'max_single_payout') = 0,
'ALTER TABLE lucky_gift_rule_versions ADD COLUMN max_single_payout BIGINT NOT NULL DEFAULT 0 COMMENT ''单次返奖上限'' AFTER max_jackpot_hits_per_user_day', '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_gift_rule_versions' AND COLUMN_NAME = 'user_hourly_payout_cap') = 0,
'ALTER TABLE lucky_gift_rule_versions ADD COLUMN user_hourly_payout_cap BIGINT NOT NULL DEFAULT 0 COMMENT ''用户UTC小时返奖上限'' AFTER max_single_payout', '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_gift_rule_versions' AND COLUMN_NAME = 'user_daily_payout_cap') = 0,
'ALTER TABLE lucky_gift_rule_versions ADD COLUMN user_daily_payout_cap BIGINT NOT NULL DEFAULT 0 COMMENT ''用户UTC日返奖上限'' AFTER user_hourly_payout_cap', '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_gift_rule_versions' AND COLUMN_NAME = 'device_daily_payout_cap') = 0,
'ALTER TABLE lucky_gift_rule_versions ADD COLUMN device_daily_payout_cap BIGINT NOT NULL DEFAULT 0 COMMENT ''设备UTC日返奖上限'' AFTER user_daily_payout_cap', '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_gift_rule_versions' AND COLUMN_NAME = 'room_hourly_payout_cap') = 0,
'ALTER TABLE lucky_gift_rule_versions ADD COLUMN room_hourly_payout_cap BIGINT NOT NULL DEFAULT 0 COMMENT ''房间UTC小时返奖上限'' AFTER device_daily_payout_cap', '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_gift_rule_versions' AND COLUMN_NAME = 'anchor_daily_payout_cap') = 0,
'ALTER TABLE lucky_gift_rule_versions ADD COLUMN anchor_daily_payout_cap BIGINT NOT NULL DEFAULT 0 COMMENT ''主播UTC日返奖上限'' AFTER room_hourly_payout_cap', '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_gift_stage_tiers' AND COLUMN_NAME = 'min_recharge_7d_coins') = 0,
'ALTER TABLE lucky_gift_stage_tiers ADD COLUMN min_recharge_7d_coins BIGINT NOT NULL DEFAULT 0 COMMENT ''normal/advanced 最近7日充值下限novice 的0仅为sentinel'' AFTER stage', '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_gift_stage_tiers' AND COLUMN_NAME = 'min_recharge_30d_coins') = 0,
'ALTER TABLE lucky_gift_stage_tiers ADD COLUMN min_recharge_30d_coins BIGINT NOT NULL DEFAULT 0 COMMENT ''normal/advanced 最近30日充值下限novice 的0仅为sentinel'' AFTER min_recharge_7d_coins', '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 = 'profit_total') = 0,
'ALTER TABLE lucky_pools ADD COLUMN profit_total BIGINT NOT NULL DEFAULT 0 COMMENT ''累计平台盈利拆账金币'' 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 = 'anchor_income_total') = 0,
'ALTER TABLE lucky_pools ADD COLUMN anchor_income_total BIGINT NOT NULL DEFAULT 0 COMMENT ''累计主播收益拆账金币'' AFTER profit_total', '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 = 'initial_seed_total') = 0,
'ALTER TABLE lucky_pools ADD COLUMN initial_seed_total BIGINT NOT NULL DEFAULT 0 COMMENT ''累计初始注资金币'' AFTER anchor_income_total', 'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- 消费门槛按 UTC 日累计,但已经赚到的机会在池不足时必须跨日保留;因此 token 属于用户/奖池状态,不能放在日表。
SET @ddl := IF((SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'lucky_user_states' AND COLUMN_NAME = 'pending_jackpot_tokens') = 0,
'ALTER TABLE lucky_user_states ADD COLUMN pending_jackpot_tokens BIGINT NOT NULL DEFAULT 0 COMMENT ''未消费大奖机会;资金不足时允许跨日保留'' AFTER loss_streak', 'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- pool_id 最长 96RTP 还拼接 :v<rule_version>,风控还拼接最长 128 的设备/房间主体,旧列宽会在合法输入上截断。
SET @ddl := IF((SELECT COALESCE(CHARACTER_MAXIMUM_LENGTH, 0) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'lucky_rtp_windows' AND COLUMN_NAME = 'scope_id') < 128,
'ALTER TABLE lucky_rtp_windows MODIFY COLUMN scope_id VARCHAR(128) NOT NULL COMMENT ''奖池+不可变规则版本作用域''', 'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl := IF((SELECT COALESCE(CHARACTER_MAXIMUM_LENGTH, 0) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'lucky_risk_counters' AND COLUMN_NAME = 'scope_id') < 255,
'ALTER TABLE lucky_risk_counters MODIFY COLUMN scope_id VARCHAR(255) NOT NULL COMMENT ''pool_id+风控主体 ID''', 'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- 三张运行状态表都是 owner 库内聚合/计数事实;热路径只锁这些小表,禁止回扫开奖结果明细计算 72h RTP 或风控额度。
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',
scope_id VARCHAR(255) NOT NULL COMMENT '风控主体 ID运行侧包含 pool 前缀',
window_type VARCHAR(32) NOT NULL COMMENT 'hour/day',
bucket_key VARCHAR(32) NOT NULL COMMENT 'UTC 窗口键',
payout_coins BIGINT NOT NULL DEFAULT 0 COMMENT '窗口内已支出可见奖励',
created_at_ms BIGINT NOT NULL COMMENT '创建时间UTC epoch ms',
updated_at_ms BIGINT NOT NULL COMMENT '更新时间UTC epoch ms',
PRIMARY KEY (app_code, scope_type, scope_id, window_type, bucket_key)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='幸运礼物多维风控窗口计数';
CREATE TABLE IF NOT EXISTS lucky_user_rtp_hour_buckets (
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码',
pool_id VARCHAR(96) NOT NULL COMMENT '幸运礼物奖池 ID',
user_id BIGINT NOT NULL COMMENT '用户 ID',
bucket_hour_ms BIGINT NOT NULL COMMENT 'UTC 小时开始时间epoch ms',
wager_coins BIGINT NOT NULL DEFAULT 0 COMMENT '小时内抽奖流水金币',
payout_coins BIGINT NOT NULL DEFAULT 0 COMMENT '小时内返奖金币',
created_at_ms BIGINT NOT NULL COMMENT '创建时间UTC epoch ms',
updated_at_ms BIGINT NOT NULL COMMENT '更新时间UTC epoch ms',
PRIMARY KEY (app_code, pool_id, user_id, bucket_hour_ms),
KEY idx_lucky_user_rtp_hour_retention (app_code, bucket_hour_ms)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户滚动72小时RTP小时桶';
CREATE TABLE IF NOT EXISTS lucky_user_rtp_boundary_events (
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码',
pool_id VARCHAR(96) NOT NULL COMMENT '幸运礼物奖池 ID',
user_id BIGINT NOT NULL COMMENT '用户 ID',
draw_id VARCHAR(128) NOT NULL COMMENT '单抽事实 ID用于幂等',
occurred_at_ms BIGINT NOT NULL COMMENT '扣费事实时间UTC epoch ms',
wager_coins BIGINT NOT NULL COMMENT '本抽流水金币',
payout_coins BIGINT NOT NULL COMMENT '本抽已承诺返奖负债',
created_at_ms BIGINT NOT NULL COMMENT '创建时间UTC epoch ms',
PRIMARY KEY (app_code, pool_id, user_id, draw_id),
KEY idx_lucky_user_rtp_boundary_range (app_code, pool_id, user_id, occurred_at_ms)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户严格滚动72小时RTP边界事件';
CREATE TABLE IF NOT EXISTS lucky_user_strategy_days (
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码',
pool_id VARCHAR(96) NOT NULL COMMENT '幸运礼物奖池 ID',
user_id BIGINT NOT NULL COMMENT '用户 ID',
stat_day DATE NOT NULL COMMENT 'UTC 自然日',
wager_coins BIGINT NOT NULL DEFAULT 0 COMMENT '当日抽奖流水金币',
payout_coins BIGINT NOT NULL DEFAULT 0 COMMENT '当日返奖金币',
spend_coins BIGINT NOT NULL DEFAULT 0 COMMENT '当日大奖资格累计消费金币',
jackpot_hits BIGINT NOT NULL DEFAULT 0 COMMENT '当日大奖命中次数',
created_at_ms BIGINT NOT NULL COMMENT '创建时间UTC epoch ms',
updated_at_ms BIGINT NOT NULL COMMENT '更新时间UTC epoch ms',
PRIMARY KEY (app_code, pool_id, user_id, stat_day),
KEY idx_lucky_user_strategy_days_retention (app_code, stat_day)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户dynamic_v3 UTC日级状态';
-- 旧库只有 lucky_draw_records 唯一键,两个尚无结果的首次请求仍可能同时穿过空读并让后到者失败。
-- 独立锁行先串行化原始 command_id后到请求再回读首次事务一次性提交的全部子抽事实。
CREATE TABLE IF NOT EXISTS lucky_gift_command_locks (
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码',
command_id VARCHAR(128) NOT NULL COMMENT '钱包已扣费命令幂等 ID',
created_at_ms BIGINT NOT NULL COMMENT '首次请求时间UTC epoch ms',
PRIMARY KEY (app_code, command_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='内部幸运礼物并发幂等锁行';
CREATE TABLE IF NOT EXISTS external_lucky_gift_draw_items (
app_code VARCHAR(32) NOT NULL COMMENT '外部 App 应用编码',
request_id VARCHAR(128) NOT NULL COMMENT '聚合请求幂等 ID',
item_index INT NOT NULL COMMENT '从1开始的顺序子抽序号',
draw_id VARCHAR(128) NOT NULL COMMENT '子抽事实 ID',
command_id VARCHAR(128) NOT NULL COMMENT '稳定子命令 ID',
external_user_id VARCHAR(128) NOT NULL COMMENT '外部 App 用户 ID',
pool_id VARCHAR(96) NOT NULL COMMENT '幸运礼物奖池 ID',
rule_version BIGINT NOT NULL COMMENT '不可变规则版本',
unit_amount BIGINT NOT NULL COMMENT '本抽实际金额',
reward_amount BIGINT NOT NULL COMMENT '本抽奖励金额',
selected_tier_id VARCHAR(96) NOT NULL COMMENT '本抽最终奖档',
multiplier_ppm BIGINT NOT NULL COMMENT '本抽倍率',
stage VARCHAR(32) NOT NULL COMMENT '充值分层',
candidate_snapshot_json JSON NOT NULL COMMENT '动态权重、删除重抽及大奖条件快照',
pool_snapshot_json JSON NOT NULL COMMENT '本抽资金拆分和池快照',
rtp_snapshot_json JSON NOT NULL COMMENT '本抽RTP快照',
reward_status VARCHAR(32) NOT NULL DEFAULT 'granted' COMMENT '外部调用方自行结算',
paid_at_ms BIGINT NOT NULL COMMENT '外部扣费时间UTC epoch ms',
created_at_ms BIGINT NOT NULL COMMENT '创建时间UTC epoch ms',
PRIMARY KEY (app_code, request_id, item_index),
UNIQUE KEY uk_external_lucky_item_draw (app_code, draw_id),
KEY idx_external_lucky_item_user (app_code, external_user_id, created_at_ms)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='外部dynamic_v3顺序子抽审计';
CREATE TABLE IF NOT EXISTS external_lucky_gift_request_locks (
app_code VARCHAR(32) NOT NULL COMMENT '外部 App 应用编码',
request_id VARCHAR(128) NOT NULL COMMENT '外部业务幂等 ID',
created_at_ms BIGINT NOT NULL COMMENT '首次请求时间UTC epoch ms',
PRIMARY KEY (app_code, request_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='外部幸运礼物并发幂等锁行';

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

@ -19,6 +19,13 @@ const (
StageNormal = "normal" StageNormal = "normal"
StageAdvanced = "advanced" StageAdvanced = "advanced"
// StrategyFixedV2 保留现网不可变规则的原始语义;只有显式发布 StrategyDynamicV3 才启用动态水位、充值加权和多维风控。
StrategyFixedV2 = "fixed_v2"
StrategyDynamicV3 = "dynamic_v3"
PoolAdjustmentIn = "in"
PoolAdjustmentOut = "out"
EventTypeLuckyGiftDrawn = "LuckyGiftDrawn" EventTypeLuckyGiftDrawn = "LuckyGiftDrawn"
EventTypeLuckyGiftRewardSettlement = "LuckyGiftRewardSettlement" EventTypeLuckyGiftRewardSettlement = "LuckyGiftRewardSettlement"
EventTypeExternalLuckyGiftDrawn = "ExternalLuckyGiftDrawn" EventTypeExternalLuckyGiftDrawn = "ExternalLuckyGiftDrawn"
@ -42,10 +49,15 @@ type Config struct {
PoolID string `json:"pool_id"` PoolID string `json:"pool_id"`
Enabled bool `json:"enabled"` Enabled bool `json:"enabled"`
RuleVersion int64 `json:"rule_version"` RuleVersion int64 `json:"rule_version"`
StrategyVersion string `json:"strategy_version"`
GiftPrice int64 `json:"gift_price"` GiftPrice int64 `json:"gift_price"`
TargetRTPPPM int64 `json:"target_rtp_ppm"` TargetRTPPPM int64 `json:"target_rtp_ppm"`
PoolRatePPM int64 `json:"pool_rate_ppm"` PoolRatePPM int64 `json:"pool_rate_ppm"`
ProfitRatePPM int64 `json:"profit_rate_ppm"`
AnchorRatePPM int64 `json:"anchor_rate_ppm"`
ControlBandPPM int64 `json:"control_band_ppm"` ControlBandPPM int64 `json:"control_band_ppm"`
// SettlementWindowWager 是 dynamic_v3 结算窗口的真实金币流水阈值;不能按参考礼物价格换算成抽数。
SettlementWindowWager int64 `json:"settlement_window_wager"`
GlobalWindowDraws int64 `json:"global_window_draws"` GlobalWindowDraws int64 `json:"global_window_draws"`
GiftWindowDraws int64 `json:"gift_window_draws"` GiftWindowDraws int64 `json:"gift_window_draws"`
NoviceMaxEquivalentDraws int64 `json:"novice_max_equivalent_draws"` NoviceMaxEquivalentDraws int64 `json:"novice_max_equivalent_draws"`
@ -54,7 +66,29 @@ type Config struct {
HighWaterPoolMultiple int64 `json:"high_water_pool_multiple"` HighWaterPoolMultiple int64 `json:"high_water_pool_multiple"`
InitialBasePool int64 `json:"initial_base_pool"` InitialBasePool int64 `json:"initial_base_pool"`
BasePoolReserve int64 `json:"base_pool_reserve"` BasePoolReserve int64 `json:"base_pool_reserve"`
InitialPoolCoins int64 `json:"initial_pool_coins"`
LossStreakGuarantee int64 `json:"loss_streak_guarantee"`
LowWatermarkCoins int64 `json:"low_watermark_coins"`
LowWaterNonzeroFactorPPM int64 `json:"low_water_nonzero_factor_ppm"`
HighWatermarkCoins int64 `json:"high_watermark_coins"`
HighWaterNonzeroFactorPPM int64 `json:"high_water_nonzero_factor_ppm"`
RechargeBoostWindowMS int64 `json:"recharge_boost_window_ms"`
RechargeBoostFactorPPM int64 `json:"recharge_boost_factor_ppm"`
JackpotMultiplierPPMs []int64 `json:"jackpot_multiplier_ppms"`
JackpotGlobalRTPMaxPPM int64 `json:"jackpot_global_rtp_max_ppm"`
JackpotUserDayRTPMaxPPM int64 `json:"jackpot_user_day_rtp_max_ppm"`
JackpotUser72hRTPMaxPPM int64 `json:"jackpot_user_72h_rtp_max_ppm"`
JackpotSpendThresholdCoins int64 `json:"jackpot_spend_threshold_coins"`
MaxJackpotHitsPerUserDay int64 `json:"max_jackpot_hits_per_user_day"`
MaxSinglePayout int64 `json:"max_single_payout"`
UserHourlyPayoutCap int64 `json:"user_hourly_payout_cap"`
UserDailyPayoutCap int64 `json:"user_daily_payout_cap"`
DeviceDailyPayoutCap int64 `json:"device_daily_payout_cap"`
RoomHourlyPayoutCap int64 `json:"room_hourly_payout_cap"`
AnchorDailyPayoutCap int64 `json:"anchor_daily_payout_cap"`
MultiplierPPMs []int64 `json:"multiplier_ppms"` MultiplierPPMs []int64 `json:"multiplier_ppms"`
// Stages 保留 dynamic_v3 的充值门槛Tiers 仍是 fixed_v2 候选选择使用的扁平运行结构。
Stages []RuleStage `json:"stages"`
Tiers []Tier `json:"tiers"` Tiers []Tier `json:"tiers"`
UpdatedByAdminID int64 `json:"updated_by_admin_id"` UpdatedByAdminID int64 `json:"updated_by_admin_id"`
CreatedAtMS int64 `json:"created_at_ms"` CreatedAtMS int64 `json:"created_at_ms"`
@ -74,17 +108,24 @@ type RuleTier struct {
// RuleStage 保存某个用户阶段的完整奖档概率。每个阶段的基础概率必须合计 1000000。 // RuleStage 保存某个用户阶段的完整奖档概率。每个阶段的基础概率必须合计 1000000。
type RuleStage struct { type RuleStage struct {
Stage string `json:"stage"` Stage string `json:"stage"`
// novice 的两个 0 仅为协议/存储 sentinelnormal/advanced 才用这两个下限提档。
MinRecharge7DCoins int64 `json:"min_recharge_7d_coins"`
MinRecharge30DCoins int64 `json:"min_recharge_30d_coins"`
Tiers []RuleTier `json:"tiers"` Tiers []RuleTier `json:"tiers"`
} }
// RuleConfig 是幸运礼物 v2 配置版本快照。比例和倍率均为 ppm业务单位转换发生在发布入口。 // RuleConfig 是幸运礼物不可变配置版本快照。fixed_v2 与 dynamic_v3 共用基础 RTP 契约,动态字段只由后者解释;
// 比例和倍率统一使用 ppm后台百分比/倍率业务单位只在发布入口转换。
type RuleConfig struct { type RuleConfig struct {
AppCode string `json:"app_code"` AppCode string `json:"app_code"`
PoolID string `json:"pool_id"` PoolID string `json:"pool_id"`
RuleVersion int64 `json:"rule_version"` RuleVersion int64 `json:"rule_version"`
StrategyVersion string `json:"strategy_version"`
Enabled bool `json:"enabled"` Enabled bool `json:"enabled"`
TargetRTPPPM int64 `json:"target_rtp_ppm"` TargetRTPPPM int64 `json:"target_rtp_ppm"`
PoolRatePPM int64 `json:"pool_rate_ppm"` PoolRatePPM int64 `json:"pool_rate_ppm"`
ProfitRatePPM int64 `json:"profit_rate_ppm"`
AnchorRatePPM int64 `json:"anchor_rate_ppm"`
SettlementWindowWager int64 `json:"settlement_window_wager"` SettlementWindowWager int64 `json:"settlement_window_wager"`
ControlBandPPM int64 `json:"control_band_ppm"` ControlBandPPM int64 `json:"control_band_ppm"`
GiftPriceReference int64 `json:"gift_price_reference"` GiftPriceReference int64 `json:"gift_price_reference"`
@ -93,6 +134,26 @@ type RuleConfig struct {
EffectiveFromMS int64 `json:"effective_from_ms"` EffectiveFromMS int64 `json:"effective_from_ms"`
CreatedByAdminID int64 `json:"created_by_admin_id"` CreatedByAdminID int64 `json:"created_by_admin_id"`
CreatedAtMS int64 `json:"created_at_ms"` CreatedAtMS int64 `json:"created_at_ms"`
InitialPoolCoins int64 `json:"initial_pool_coins"`
LossStreakGuarantee int64 `json:"loss_streak_guarantee"`
LowWatermarkCoins int64 `json:"low_watermark_coins"`
LowWaterNonzeroFactorPPM int64 `json:"low_water_nonzero_factor_ppm"`
HighWatermarkCoins int64 `json:"high_watermark_coins"`
HighWaterNonzeroFactorPPM int64 `json:"high_water_nonzero_factor_ppm"`
RechargeBoostWindowMS int64 `json:"recharge_boost_window_ms"`
RechargeBoostFactorPPM int64 `json:"recharge_boost_factor_ppm"`
JackpotMultiplierPPMs []int64 `json:"jackpot_multiplier_ppms"`
JackpotGlobalRTPMaxPPM int64 `json:"jackpot_global_rtp_max_ppm"`
JackpotUserDayRTPMaxPPM int64 `json:"jackpot_user_day_rtp_max_ppm"`
JackpotUser72hRTPMaxPPM int64 `json:"jackpot_user_72h_rtp_max_ppm"`
JackpotSpendThresholdCoins int64 `json:"jackpot_spend_threshold_coins"`
MaxJackpotHitsPerUserDay int64 `json:"max_jackpot_hits_per_user_day"`
MaxSinglePayout int64 `json:"max_single_payout"`
UserHourlyPayoutCap int64 `json:"user_hourly_payout_cap"`
UserDailyPayoutCap int64 `json:"user_daily_payout_cap"`
DeviceDailyPayoutCap int64 `json:"device_daily_payout_cap"`
RoomHourlyPayoutCap int64 `json:"room_hourly_payout_cap"`
AnchorDailyPayoutCap int64 `json:"anchor_daily_payout_cap"`
Stages []RuleStage `json:"stages"` Stages []RuleStage `json:"stages"`
} }
@ -117,6 +178,10 @@ type DrawCommand struct {
PaidAtMS int64 PaidAtMS int64
VisibleRegionID int64 VisibleRegionID int64
CountryID int64 CountryID int64
Recharge7DCoins int64
Recharge30DCoins int64
LastRechargedAtMS int64
GiftIncomeCoins int64
// Sender* 是扣费时刻的公开展示快照owner outbox 必须携带快照,避免下游为一条飘屏同步反查 user-service。 // Sender* 是扣费时刻的公开展示快照owner outbox 必须携带快照,避免下游为一条飘屏同步反查 user-service。
SenderName string SenderName string
SenderAvatar string SenderAvatar string
@ -131,6 +196,7 @@ type CheckResult struct {
GiftID string GiftID string
GiftPrice int64 GiftPrice int64
RuleVersion int64 RuleVersion int64
StrategyVersion string
TargetRTPPPM int64 TargetRTPPPM int64
ExperiencePool string ExperiencePool string
} }
@ -191,11 +257,14 @@ type DrawSummary struct {
type PoolBalance struct { type PoolBalance struct {
AppCode string AppCode string
PoolID string PoolID string
StrategyVersion string
Balance int64 Balance int64
ReserveFloor int64 ReserveFloor int64
AvailableBalance int64 AvailableBalance int64
TotalIn int64 TotalIn int64
TotalOut int64 TotalOut int64
ManualCreditTotal int64
ManualDebitTotal int64
Materialized bool Materialized bool
UpdatedAtMS int64 UpdatedAtMS int64
} }
@ -203,6 +272,41 @@ type PoolBalance struct {
type PoolBalanceQuery struct { type PoolBalanceQuery struct {
AppCode string AppCode string
PoolID 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 { type AdminConfigQuery struct {
@ -229,6 +333,8 @@ type DrawQuery struct {
type ExternalDrawCommand struct { type ExternalDrawCommand struct {
AppCode string AppCode string
ExternalUserID string ExternalUserID string
// DeviceID 是外部调用方在自己认证/签名边界内绑定的稳定设备标识dynamic_v3 必填。
DeviceID string
RequestID string RequestID string
GiftCount int64 GiftCount int64
UnitAmount int64 UnitAmount int64

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,454 @@
package luckygift
import (
"fmt"
"reflect"
"testing"
)
type scriptedStrategyRandom struct {
t *testing.T
indexes []int64
bounds []int64
position int
}
func (r *scriptedStrategyRandom) Int63n(n int64) (int64, error) {
r.t.Helper()
if r.position >= len(r.indexes) {
return 0, fmt.Errorf("scripted RNG exhausted at bound %d", n)
}
if len(r.bounds) > r.position && r.bounds[r.position] != n {
r.t.Fatalf("random call %d bound=%d want=%d", r.position, n, r.bounds[r.position])
}
value := r.indexes[r.position]
r.position++
return value, nil
}
func strategyTestConfig() StrategyConfig {
config := DefaultLuckyGiftStrategyConfig()
// The 0/5/50/200/1000 table is the supplied image's deliberately unsafe
// 2650% example. It is a test fixture only; the exported default stays at 98%.
config.Tiers = []StrategyTier{
{ID: "0x", MultiplierPPM: 0, BaseWeightPPM: 600_000, Enabled: true},
{ID: "5x", MultiplierPPM: 5_000_000, BaseWeightPPM: 200_000, Enabled: true},
{ID: "50x", MultiplierPPM: 50_000_000, BaseWeightPPM: 150_000, Enabled: true},
{ID: "200x", MultiplierPPM: 200_000_000, BaseWeightPPM: 40_000, Jackpot: true, JackpotWeight: 4, Enabled: true},
{ID: "500x", MultiplierPPM: 500_000_000, BaseWeightPPM: 0, Jackpot: true, JackpotWeight: 2, Enabled: true},
{ID: "1000x", MultiplierPPM: 1_000_000_000, BaseWeightPPM: 10_000, Jackpot: true, JackpotWeight: 1, Enabled: true},
}
// Core P/W tests isolate the base distribution; dynamic factors have dedicated
// boundary tests below and must not silently move scripted intervals here.
config.LowWaterThresholdCoins = 0
config.HighWaterThresholdCoins = 1_000_000_000
config.LowWaterFactorPPM = StrategyPPMScale
config.HighWaterFactorPPM = StrategyPPMScale
config.RechargeFactorPPM = StrategyPPMScale
config.JackpotMechanism1Enabled = false
config.JackpotMechanism2Enabled = false
config.DailyJackpotLimit = 1_000_000
return config
}
func strategyTestInput(price int64) StrategyInput {
return StrategyInput{GiftPriceCoins: price}
}
func TestLuckyGiftStrategyDeletesUnaffordableTierAndZeroBeforeRedraw(t *testing.T) {
config := strategyTestConfig()
random := &scriptedStrategyRandom{
t: t,
// 950000 selects 200x from the original 1,000,000 range. After deleting
// 200x+0x, 359999 selects 1000x; the last zero selects affordable 5x.
indexes: []int64{950_000, 359_999, 0},
bounds: []int64{1_000_000, 360_000, 350_000},
}
decision, err := DecideLuckyGiftStrategy(config, StrategyState{PoolBalanceCoins: 800}, strategyTestInput(10), random)
if err != nil {
t.Fatal(err)
}
if decision.SelectedTier.ID != "5x" || decision.PayoutCoins != 50 || decision.PoolAfterCoins != 750 {
t.Fatalf("decision=%+v", decision)
}
if got, want := decision.Trace.OriginalTierID, "200x"; got != want {
t.Fatalf("original tier=%s want=%s", got, want)
}
if got, want := decision.Trace.RedrawTierIDs, []string{"1000x", "5x"}; !reflect.DeepEqual(got, want) {
t.Fatalf("redraws=%v want=%v", got, want)
}
wantRemoved := []StrategyRemovalTrace{
{TierID: "200x", Reason: StrategyReasonPoolInsufficient},
{TierID: "0x", Reason: StrategyRemovalPWRedrawZero},
{TierID: "1000x", Reason: StrategyReasonPoolInsufficient},
}
if !reflect.DeepEqual(decision.Trace.Removed, wantRemoved) {
t.Fatalf("removed=%+v want=%+v", decision.Trace.Removed, wantRemoved)
}
}
func TestLuckyGiftStrategyPWEqualityAndCandidateExhaustion(t *testing.T) {
t.Run("W equals P is payable", func(t *testing.T) {
config := strategyTestConfig()
decision, err := DecideLuckyGiftStrategy(config, StrategyState{PoolBalanceCoins: 2_000}, strategyTestInput(10), &scriptedStrategyRandom{t: t, indexes: []int64{950_000}, bounds: []int64{1_000_000}})
if err != nil {
t.Fatal(err)
}
if decision.SelectedTier.ID != "200x" || decision.PayoutCoins != 2_000 || decision.PoolAfterCoins != 0 {
t.Fatalf("P=W decision=%+v", decision)
}
})
t.Run("all positive tiers impossible falls back to zero", func(t *testing.T) {
config := strategyTestConfig()
random := &scriptedStrategyRandom{
t: t,
// Exhaust 1000x, 200x, 50x, then 5x. Zero is removed with the first
// failed P/W comparison, so no extra random call can hide exhaustion.
indexes: []int64{999_999, 350_000, 349_999, 0},
bounds: []int64{1_000_000, 390_000, 350_000, 200_000},
}
decision, err := DecideLuckyGiftStrategy(config, StrategyState{PoolBalanceCoins: 49}, strategyTestInput(10), random)
if err != nil {
t.Fatal(err)
}
if decision.PayoutCoins != 0 || decision.SelectedTier.ID != "0x" || decision.Trace.FinalReason != StrategyReasonNoPayableTier {
t.Fatalf("exhausted decision=%+v", decision)
}
if decision.NextState.PoolBalanceCoins < 0 {
t.Fatalf("pool became negative: %d", decision.NextState.PoolBalanceCoins)
}
})
}
func TestLuckyGiftStrategyMissProtectionSixthDraw(t *testing.T) {
config := strategyTestConfig()
state := StrategyState{PoolBalanceCoins: 800, ConsecutiveZeroDraws: 5}
decision, err := DecideLuckyGiftStrategy(config, state, strategyTestInput(10), &scriptedStrategyRandom{t: t, indexes: []int64{0}, bounds: []int64{400_000}})
if err != nil {
t.Fatal(err)
}
if decision.SelectedTier.ID != "5x" || decision.PayoutCoins != 50 || decision.NextState.ConsecutiveZeroDraws != 0 {
t.Fatalf("protected decision=%+v", decision)
}
if len(decision.Trace.Removed) == 0 || decision.Trace.Removed[0].Reason != StrategyRemovalMissProtection {
t.Fatalf("zero exclusion not traced: %+v", decision.Trace.Removed)
}
// Protection cannot manufacture money: when even 5x exceeds P, the strategy
// returns zero and leaves an explicit blocked reason instead of overdrawing.
blocked, err := DecideLuckyGiftStrategy(config, StrategyState{PoolBalanceCoins: 49, ConsecutiveZeroDraws: 5}, strategyTestInput(10), &scriptedStrategyRandom{t: t})
if err != nil {
t.Fatal(err)
}
if blocked.PayoutCoins != 0 || blocked.Trace.FinalReason != StrategyReasonMissProtectionBlocked || blocked.NextState.PoolBalanceCoins != 49 {
t.Fatalf("blocked protection=%+v", blocked)
}
}
func TestLuckyGiftStrategyDynamicWeightBoundariesAndMultiplication(t *testing.T) {
config := strategyTestConfig()
config.LowWaterThresholdCoins = 1_000
config.HighWaterThresholdCoins = 2_000
config.LowWaterFactorPPM = 700_000
config.HighWaterFactorPPM = 1_300_000
config.RechargeFactorPPM = 1_100_000
config.JackpotMechanism1Enabled = false
config.JackpotMechanism2Enabled = false
tests := []struct {
name string
pool int64
rechargeAge int64
hasRecharge bool
want5x int64
wantFactorNames []string
}{
{name: "low below threshold", pool: 999, want5x: 140_000, wantFactorNames: []string{"low_water"}},
{name: "low equality is middle", pool: 1_000, want5x: 200_000, wantFactorNames: []string{"identity"}},
{name: "high equality is middle", pool: 2_000, want5x: 200_000, wantFactorNames: []string{"identity"}},
{name: "high above threshold", pool: 2_001, want5x: 260_000, wantFactorNames: []string{"high_water"}},
{name: "recharge starts inclusive and multiplies low", pool: 999, hasRecharge: true, rechargeAge: 0, want5x: 154_000, wantFactorNames: []string{"low_water", "recent_recharge"}},
{name: "recharge end minus one inclusive", pool: 1_500, hasRecharge: true, rechargeAge: 299_999, want5x: 220_000, wantFactorNames: []string{"recent_recharge"}},
{name: "recharge end exclusive", pool: 1_500, hasRecharge: true, rechargeAge: 300_000, want5x: 200_000, wantFactorNames: []string{"identity"}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
input := strategyTestInput(10)
input.NowMS = 1_000_000
input.HasRechargeFact = tt.hasRecharge
input.LastRechargeAtMS = input.NowMS - tt.rechargeAge
_, traces, err := PreviewLuckyGiftStrategyWeights(config, StrategyState{PoolBalanceCoins: tt.pool}, input)
if err != nil {
t.Fatal(err)
}
trace := findWeightTrace(t, traces, "5x")
if trace.AdjustedWeightPPM != tt.want5x {
t.Fatalf("5x adjusted=%d want=%d full=%+v", trace.AdjustedWeightPPM, tt.want5x, trace)
}
factorNames := make([]string, 0, len(trace.Factors))
for _, factor := range trace.Factors {
factorNames = append(factorNames, factor.Name)
}
if !reflect.DeepEqual(factorNames, tt.wantFactorNames) {
t.Fatalf("factor names=%v want=%v", factorNames, tt.wantFactorNames)
}
var total int64
for _, item := range traces {
total += item.AdjustedWeightPPM
}
if total != StrategyPPMScale {
t.Fatalf("weights sum=%d", total)
}
})
}
}
func TestLuckyGiftStrategyJackpotMechanismOneRequiresAllNonZeroWindows(t *testing.T) {
config := strategyTestConfig()
config.JackpotMechanism1Enabled = true
config.DailyJackpotLimit = 5
base := StrategyState{
PoolBalanceCoins: 10_000,
GlobalRTP: StrategyRTP{WagerCoins: 100, PayoutCoins: 98},
UserDayRTP: StrategyRTP{WagerCoins: 100, PayoutCoins: 96},
User72HourRTP: StrategyRTP{WagerCoins: 100, PayoutCoins: 96},
}
tests := []struct {
name string
mutate func(*StrategyState)
wantPrize bool
}{
{name: "all inclusive boundaries pass", wantPrize: true},
{name: "global above 98", mutate: func(s *StrategyState) { s.GlobalRTP.PayoutCoins = 99 }},
{name: "day above 96", mutate: func(s *StrategyState) { s.UserDayRTP.PayoutCoins = 97 }},
{name: "72h above 96", mutate: func(s *StrategyState) { s.User72HourRTP.PayoutCoins = 97 }},
{name: "global denominator zero", mutate: func(s *StrategyState) { s.GlobalRTP = StrategyRTP{} }},
{name: "day denominator zero", mutate: func(s *StrategyState) { s.UserDayRTP = StrategyRTP{} }},
{name: "72h denominator zero", mutate: func(s *StrategyState) { s.User72HourRTP = StrategyRTP{} }},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
state := base
if tt.mutate != nil {
tt.mutate(&state)
}
decision, err := DecideLuckyGiftStrategy(config, state, strategyTestInput(10), NewSeededStrategyRandom(7))
if err != nil {
t.Fatal(err)
}
got := decision.JackpotMechanism == StrategyJackpotMechanismRTPCompensation
if got != tt.wantPrize {
t.Fatalf("mechanism1=%v want=%v trace=%+v", got, tt.wantPrize, decision.Trace)
}
})
}
}
func TestLuckyGiftStrategyMilestoneTokenRetainedThenConsumed(t *testing.T) {
if got := MilestoneTokensEarned(49, 101, 50); got != 2 {
t.Fatalf("crossed tokens=%d want=2", got)
}
config := strategyTestConfig()
config.JackpotMechanism2Enabled = true
config.DailyJackpotLimit = 5
config.MilestoneSpendCoins = 50
state := StrategyState{PoolBalanceCoins: 49, PendingMilestoneTokens: 1}
blocked, err := DecideLuckyGiftStrategy(config, state, strategyTestInput(10), NewSeededStrategyRandom(11))
if err != nil {
t.Fatal(err)
}
if blocked.ConsumedMilestoneToken || blocked.NextState.PendingMilestoneTokens != 1 || !blocked.Trace.MilestoneTokenRetained {
t.Fatalf("blocked token decision=%+v", blocked)
}
if len(blocked.Trace.Removed) < 3 {
t.Fatalf("blocked jackpot removals=%+v want all 200/500/1000 tiers", blocked.Trace.Removed)
}
for _, removed := range blocked.Trace.Removed[:3] {
if removed.Reason != StrategyReasonPoolInsufficient {
t.Fatalf("blocked jackpot removal=%+v", removed)
}
}
state = blocked.NextState
state.PoolBalanceCoins = 10_000
paid, err := DecideLuckyGiftStrategy(config, state, strategyTestInput(10), NewSeededStrategyRandom(12))
if err != nil {
t.Fatal(err)
}
if !paid.ConsumedMilestoneToken || paid.NextState.PendingMilestoneTokens != 0 || !paid.Jackpot || paid.PayoutCoins <= 0 {
t.Fatalf("consumed token decision=%+v", paid)
}
// Crossing happens during finalize, after this draw has already been selected.
// The token must therefore be persisted for the next request, not consumed now.
crossingState := StrategyState{PoolBalanceCoins: 1_000, UserDaySpendCoins: 40}
crossing, err := DecideLuckyGiftStrategy(config, crossingState, strategyTestInput(10), &scriptedStrategyRandom{t: t, indexes: []int64{0}, bounds: []int64{1_000_000}})
if err != nil {
t.Fatal(err)
}
if crossing.ConsumedMilestoneToken || crossing.NextState.PendingMilestoneTokens != 1 || crossing.Trace.MilestoneTokensEarned != 1 {
t.Fatalf("crossing decision=%+v", crossing)
}
}
func TestLuckyGiftStrategyDailyJackpotLimitAlsoConstrainsBaseDraw(t *testing.T) {
config := strategyTestConfig()
config.JackpotMechanism2Enabled = true
config.DailyJackpotLimit = 5
state := StrategyState{PoolBalanceCoins: 20_000, PendingMilestoneTokens: 1, UserDailyJackpotWins: 5}
// Milestone is blocked first; ordinary draw then selects 200x, removes it for
// the same hard daily limit, removes zero, and redraws an allowed 5x.
random := &scriptedStrategyRandom{t: t, indexes: []int64{950_000, 600_000}, bounds: []int64{1_000_000, 960_000}}
decision, err := DecideLuckyGiftStrategy(config, state, strategyTestInput(10), random)
if err != nil {
t.Fatal(err)
}
if decision.Jackpot || decision.SelectedTier.ID != "5x" || decision.NextState.PendingMilestoneTokens != 1 || decision.NextState.UserDailyJackpotWins != 5 {
t.Fatalf("daily-limit decision=%+v", decision)
}
if decision.Trace.Draws[0].RemovedReason != StrategyReasonDailyJackpotLimit {
t.Fatalf("daily-limit removal=%+v", decision.Trace.Draws)
}
}
func TestLuckyGiftStrategySixRiskCapacitiesAreHardInclusiveBounds(t *testing.T) {
config := strategyTestConfig()
config.Tiers = []StrategyTier{
{ID: "0x", MultiplierPPM: 0, BaseWeightPPM: 0, Enabled: true},
{ID: "5x", MultiplierPPM: 5_000_000, BaseWeightPPM: StrategyPPMScale, Enabled: true},
}
setters := []struct {
name string
set func(*StrategyRiskCapacity, int64)
}{
{name: "single draw", set: func(c *StrategyRiskCapacity, v int64) { c.SingleDrawCoins = v }},
{name: "user hour", set: func(c *StrategyRiskCapacity, v int64) { c.UserHourCoins = v }},
{name: "user day", set: func(c *StrategyRiskCapacity, v int64) { c.UserDayCoins = v }},
{name: "device day", set: func(c *StrategyRiskCapacity, v int64) { c.DeviceDayCoins = v }},
{name: "room hour", set: func(c *StrategyRiskCapacity, v int64) { c.RoomHourCoins = v }},
{name: "anchor day", set: func(c *StrategyRiskCapacity, v int64) { c.AnchorDayCoins = v }},
}
for _, setter := range setters {
t.Run(setter.name, func(t *testing.T) {
capacity := StrategyRiskCapacity{Enabled: true, SingleDrawCoins: 1_000, UserHourCoins: 1_000, UserDayCoins: 1_000, DeviceDayCoins: 1_000, RoomHourCoins: 1_000, AnchorDayCoins: 1_000}
setter.set(&capacity, 50)
input := strategyTestInput(10)
input.RiskCapacity = capacity
allowed, err := DecideLuckyGiftStrategy(config, StrategyState{PoolBalanceCoins: 50}, input, NewSeededStrategyRandom(1))
if err != nil {
t.Fatal(err)
}
if allowed.PayoutCoins != 50 || allowed.PoolAfterCoins != 0 {
t.Fatalf("boundary must pass: %+v", allowed)
}
setter.set(&capacity, 49)
input.RiskCapacity = capacity
blocked, err := DecideLuckyGiftStrategy(config, StrategyState{PoolBalanceCoins: 50}, input, NewSeededStrategyRandom(1))
if err != nil {
t.Fatal(err)
}
if blocked.PayoutCoins != 0 || blocked.Trace.FinalReason != StrategyReasonNoPayableTier {
t.Fatalf("below boundary must block: %+v", blocked)
}
if len(blocked.Trace.Removed) != 1 || blocked.Trace.Removed[0].TierID != "5x" || blocked.Trace.Removed[0].Reason != StrategyReasonRiskCapacity {
t.Fatalf("risk retry must not remove zero: %+v", blocked.Trace.Removed)
}
})
}
}
func TestLuckyGiftStrategyRechargeStagesAndFundConservation(t *testing.T) {
config := DefaultLuckyGiftStrategyConfig()
config.RechargeStages = []StrategyRechargeStage{
{Name: StageNovice, MinRecharge7DCoins: 0, MinRecharge30DCoins: 0},
{Name: StageNormal, MinRecharge7DCoins: 100, MinRecharge30DCoins: 500},
{Name: StageAdvanced, MinRecharge7DCoins: 500, MinRecharge30DCoins: 2_000},
}
stageCases := []struct {
seven, thirty int64
want string
}{
{seven: 0, thirty: 0, want: StageNovice},
// 两个非零充值仍低于 normal必须留在 novicenovice 不是“只有零充值”。
{seven: 99, thirty: 499, want: StageNovice},
{seven: 100, thirty: 499, want: StageNovice},
{seven: 99, thirty: 500, want: StageNovice},
// 两维等于门槛都算达到;任一维未达 advanced 时仍保持 normal。
{seven: 100, thirty: 500, want: StageNormal},
{seven: 500, thirty: 1_999, want: StageNormal},
{seven: 499, thirty: 2_000, want: StageNormal},
{seven: 500, thirty: 2_000, want: StageAdvanced},
}
for _, tt := range stageCases {
if got := SelectLuckyGiftRechargeStage(config, tt.seven, tt.thirty); got != tt.want {
t.Fatalf("stage(%d,%d)=%s want=%s", tt.seven, tt.thirty, got, tt.want)
}
}
split, err := SplitLuckyGiftStrategyFunds(100, config)
if err != nil {
t.Fatal(err)
}
if split != (StrategyFundSplit{PublicPoolCoins: 98, ProfitPoolCoins: 1, AnchorReturnCoins: 1}) {
t.Fatalf("split=%+v", split)
}
if split.PublicPoolCoins+split.ProfitPoolCoins+split.AnchorReturnCoins != 100 {
t.Fatal("fund split did not conserve money")
}
small, err := SplitLuckyGiftStrategyFunds(10, config)
if err != nil {
t.Fatal(err)
}
if small != (StrategyFundSplit{PublicPoolCoins: 9, ProfitPoolCoins: 1, AnchorReturnCoins: 0}) {
t.Fatalf("small split=%+v want public floor with residue in profit", small)
}
if initial := InitialLuckyGiftStrategyState(config); initial.PoolBalanceCoins != config.ColdStartPoolCoins || initial.PoolBalanceCoins < 0 {
t.Fatalf("initial=%+v", initial)
}
}
func TestDefaultLuckyGiftStrategyNominalEVIsExactlyNinetyEightPercent(t *testing.T) {
config := DefaultLuckyGiftStrategyConfig()
var weightedMultiplierMicros int64
for _, tier := range config.Tiers {
weightedMultiplierMicros += tier.BaseWeightPPM * tier.MultiplierPPM / StrategyPPMScale
}
if weightedMultiplierMicros != 980_000 {
t.Fatalf("default nominal EV=%dppm want=980000ppm", weightedMultiplierMicros)
}
for _, tier := range config.Tiers {
if tier.Jackpot && tier.BaseWeightPPM != 0 {
t.Fatalf("default jackpot tier %s leaked into ordinary probability", tier.ID)
}
}
}
func TestLuckyGiftStrategyCombinationPriority(t *testing.T) {
config := strategyTestConfig()
config.JackpotMechanism1Enabled = true
config.JackpotMechanism2Enabled = true
config.DailyJackpotLimit = 5
state := StrategyState{
PoolBalanceCoins: 10_000, ConsecutiveZeroDraws: 5, PendingMilestoneTokens: 1,
GlobalRTP: StrategyRTP{WagerCoins: 100, PayoutCoins: 98},
UserDayRTP: StrategyRTP{WagerCoins: 100, PayoutCoins: 96},
User72HourRTP: StrategyRTP{WagerCoins: 100, PayoutCoins: 96},
}
decision, err := DecideLuckyGiftStrategy(config, state, strategyTestInput(10), NewSeededStrategyRandom(8))
if err != nil {
t.Fatal(err)
}
if decision.JackpotMechanism != StrategyJackpotMechanismMilestone || !decision.ConsumedMilestoneToken {
t.Fatalf("milestone must outrank mechanism1/miss protection: %+v", decision)
}
}
func findWeightTrace(t *testing.T, traces []StrategyWeightTrace, tierID string) StrategyWeightTrace {
t.Helper()
for _, trace := range traces {
if trace.TierID == tierID {
return trace
}
}
t.Fatalf("weight trace %s not found", tierID)
return StrategyWeightTrace{}
}

View File

@ -13,40 +13,45 @@ const (
highTierMultiplierPPM int64 = 10_000_000 highTierMultiplierPPM int64 = 10_000_000
) )
// DefaultRuleConfig 返回 v2 配置草稿。草稿默认 disabled只有后台发布后才写入不可变版本表。 // DefaultRuleConfig 返回 dynamic_v3 配置草稿。大奖累计消费门槛、充值分层和金额型风控上限都没有跨 App 通用值,
// 因而保留未配置 sentinel 且草稿默认 disabled运营显式补齐这些值并启用后才允许发布可运行版本。
func DefaultRuleConfig(appCode, poolID string) domain.RuleConfig { func DefaultRuleConfig(appCode, poolID string) domain.RuleConfig {
poolID = normalizePoolID(poolID) poolID = normalizePoolID(poolID)
return domain.RuleConfig{ return domain.RuleConfig{
AppCode: appCode, AppCode: appCode,
PoolID: poolID, PoolID: poolID,
StrategyVersion: domain.StrategyDynamicV3,
Enabled: false, Enabled: false,
TargetRTPPPM: 950_000, TargetRTPPPM: 980_000,
PoolRatePPM: 960_000, PoolRatePPM: 980_000,
ProfitRatePPM: 10_000,
AnchorRatePPM: 10_000,
SettlementWindowWager: 1_000_000, SettlementWindowWager: 1_000_000,
ControlBandPPM: 10_000, ControlBandPPM: 30_000,
GiftPriceReference: 100, GiftPriceReference: 100,
// dynamic_v3 的账本从 0 开始,任何启动资金都必须经过带管理员和原因的人工注资事实。
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,
JackpotMultiplierPPMs: []int64{200_000_000, 500_000_000, 1_000_000_000},
JackpotGlobalRTPMaxPPM: 980_000,
JackpotUserDayRTPMaxPPM: 960_000,
JackpotUser72hRTPMaxPPM: 960_000,
MaxJackpotHitsPerUserDay: 5,
// 用户阶段按累计金币流水折算的等价抽数推进;后台仍看到“抽数”,运行侧不会再被不同价格礼物误导。 // 用户阶段按累计金币流水折算的等价抽数推进;后台仍看到“抽数”,运行侧不会再被不同价格礼物误导。
NoviceMaxEquivalentDraws: 2_000, NoviceMaxEquivalentDraws: 2_000,
NormalMaxEquivalentDraws: 20_000, NormalMaxEquivalentDraws: 20_000,
Stages: []domain.RuleStage{ Stages: []domain.RuleStage{
defaultRuleStage(domain.StageNovice, []ruleTierSeed{ // novice 的 0/0 只是协议和存储 sentinel运行时未同时达到 normal 两个下限的用户都回落 novice。
{id: "novice_none", multiplierPPM: 0, weightPPM: 100_000}, defaultRuleStage(domain.StageNovice, 0, 0, defaultSafeTierSeeds(domain.StageNovice)),
{id: "novice_0_5x", multiplierPPM: 500_000, weightPPM: 200_000}, // disabled 草稿不猜测金币门槛normal/advanced 必须由运营按 App 口径配置后才能启用。
{id: "novice_1x", multiplierPPM: 1_000_000, weightPPM: 550_000}, defaultRuleStage(domain.StageNormal, 0, 0, defaultSafeTierSeeds(domain.StageNormal)),
{id: "novice_2x", multiplierPPM: 2_000_000, weightPPM: 150_000}, defaultRuleStage(domain.StageAdvanced, 0, 0, defaultSafeTierSeeds(domain.StageAdvanced)),
}),
defaultRuleStage(domain.StageNormal, []ruleTierSeed{
{id: "normal_none", multiplierPPM: 0, weightPPM: 100_000},
{id: "normal_0_5x", multiplierPPM: 500_000, weightPPM: 200_000},
{id: "normal_1x", multiplierPPM: 1_000_000, weightPPM: 550_000},
{id: "normal_2x", multiplierPPM: 2_000_000, weightPPM: 150_000},
}),
defaultRuleStage(domain.StageAdvanced, []ruleTierSeed{
{id: "advanced_none", multiplierPPM: 0, weightPPM: 270_000},
{id: "advanced_1x", multiplierPPM: 1_000_000, weightPPM: 600_000},
{id: "advanced_2x", multiplierPPM: 2_000_000, weightPPM: 100_000},
{id: "advanced_5x", multiplierPPM: 5_000_000, weightPPM: 30_000},
}),
}, },
} }
} }
@ -57,8 +62,24 @@ type ruleTierSeed struct {
weightPPM int64 weightPPM int64
} }
func defaultRuleStage(stage string, tiers []ruleTierSeed) domain.RuleStage { // defaultSafeTierSeeds 保留明确的 0x 和少量 0.5x 小奖,并让每个阶段的静态期望精确为 98%。
out := domain.RuleStage{Stage: stage, Tiers: make([]domain.RuleTier, 0, len(tiers))} // 200x/500x/1000x 只登记为 jackpot 倍率,不在缺少产品概率时照搬图片示例塞进基础 tier。
func defaultSafeTierSeeds(stage string) []ruleTierSeed {
return []ruleTierSeed{
{id: stage + "_none", multiplierPPM: 0, weightPPM: 50_000},
{id: stage + "_0_5x", multiplierPPM: 500_000, weightPPM: 40_000},
{id: stage + "_1x", multiplierPPM: 1_000_000, weightPPM: 860_000},
{id: stage + "_2x", multiplierPPM: 2_000_000, weightPPM: 50_000},
}
}
func defaultRuleStage(stage string, minRecharge7DCoins, minRecharge30DCoins int64, tiers []ruleTierSeed) domain.RuleStage {
out := domain.RuleStage{
Stage: stage,
MinRecharge7DCoins: minRecharge7DCoins,
MinRecharge30DCoins: minRecharge30DCoins,
Tiers: make([]domain.RuleTier, 0, len(tiers)),
}
for _, tier := range tiers { for _, tier := range tiers {
out.Tiers = append(out.Tiers, domain.RuleTier{ out.Tiers = append(out.Tiers, domain.RuleTier{
Stage: stage, Stage: stage,
@ -74,6 +95,11 @@ func defaultRuleStage(stage string, tiers []ruleTierSeed) domain.RuleStage {
func normalizeRuleConfig(config domain.RuleConfig) domain.RuleConfig { func normalizeRuleConfig(config domain.RuleConfig) domain.RuleConfig {
config.PoolID = normalizePoolID(config.PoolID) config.PoolID = normalizePoolID(config.PoolID)
config.StrategyVersion = strings.ToLower(strings.TrimSpace(config.StrategyVersion))
if config.StrategyVersion == "" {
// 空值只可能来自升级前的客户端或旧持久行;归一为 fixed_v2 才不会让新动态校验改变历史发布语义。
config.StrategyVersion = domain.StrategyFixedV2
}
for stageIndex, stage := range config.Stages { for stageIndex, stage := range config.Stages {
stage.Stage = strings.TrimSpace(stage.Stage) stage.Stage = strings.TrimSpace(stage.Stage)
for tierIndex, tier := range stage.Tiers { for tierIndex, tier := range stage.Tiers {
@ -111,6 +137,13 @@ func validateRuleConfig(config domain.RuleConfig) error {
if config.NoviceMaxEquivalentDraws < 0 || config.NormalMaxEquivalentDraws < config.NoviceMaxEquivalentDraws { if config.NoviceMaxEquivalentDraws < 0 || config.NormalMaxEquivalentDraws < config.NoviceMaxEquivalentDraws {
return xerr.New(xerr.InvalidArgument, "equivalent draw stage thresholds are invalid") return xerr.New(xerr.InvalidArgument, "equivalent draw stage thresholds are invalid")
} }
strategyVersion := strings.ToLower(strings.TrimSpace(config.StrategyVersion))
if strategyVersion == "" {
strategyVersion = domain.StrategyFixedV2
}
if strategyVersion != domain.StrategyFixedV2 && strategyVersion != domain.StrategyDynamicV3 {
return xerr.New(xerr.InvalidArgument, fmt.Sprintf("unsupported lucky gift strategy version: %s", config.StrategyVersion))
}
stageMap := make(map[string]domain.RuleStage, len(config.Stages)) stageMap := make(map[string]domain.RuleStage, len(config.Stages))
for _, stage := range config.Stages { for _, stage := range config.Stages {
if !validRuleStage(stage.Stage) { if !validRuleStage(stage.Stage) {
@ -133,6 +166,128 @@ func validateRuleConfig(config domain.RuleConfig) error {
return err return err
} }
} }
if strategyVersion == domain.StrategyDynamicV3 {
if err := validateDynamicRuleConfig(config, stageMap); err != nil {
return err
}
}
return nil
}
// validateDynamicRuleConfig 只约束 dynamic_v3fixed_v2 的历史不可变版本即使没有这些字段也必须可以继续读取和重发。
func validateDynamicRuleConfig(config domain.RuleConfig, stages map[string]domain.RuleStage) error {
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 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")
}
if config.LowWatermarkCoins <= 0 || config.HighWatermarkCoins <= config.LowWatermarkCoins {
return xerr.New(xerr.InvalidArgument, "dynamic_v3 watermarks must be positive and high watermark must exceed low watermark")
}
if config.LowWaterNonzeroFactorPPM <= 0 || config.LowWaterNonzeroFactorPPM >= ppmScale {
return xerr.New(xerr.InvalidArgument, "dynamic_v3 low-water nonzero factor must be between 0% and 100%")
}
if config.HighWaterNonzeroFactorPPM <= ppmScale {
return xerr.New(xerr.InvalidArgument, "dynamic_v3 high-water nonzero factor must exceed 100%")
}
if config.RechargeBoostWindowMS <= 0 || config.RechargeBoostFactorPPM <= ppmScale {
return xerr.New(xerr.InvalidArgument, "dynamic_v3 recharge boost window must be positive and factor must exceed 100%")
}
if err := validateJackpotMultipliers(config.JackpotMultiplierPPMs); err != nil {
return err
}
if config.JackpotGlobalRTPMaxPPM <= 0 || config.JackpotGlobalRTPMaxPPM > ppmScale ||
config.JackpotUserDayRTPMaxPPM <= 0 || config.JackpotUserDayRTPMaxPPM > config.JackpotGlobalRTPMaxPPM ||
config.JackpotUser72hRTPMaxPPM <= 0 || config.JackpotUser72hRTPMaxPPM > config.JackpotGlobalRTPMaxPPM {
return xerr.New(xerr.InvalidArgument, "dynamic_v3 jackpot RTP limits must be positive; user limits cannot exceed the global limit")
}
if config.MaxJackpotHitsPerUserDay <= 0 {
return xerr.New(xerr.InvalidArgument, "dynamic_v3 daily jackpot hit cap must be positive")
}
if config.JackpotSpendThresholdCoins < 0 {
return xerr.New(xerr.InvalidArgument, "dynamic_v3 jackpot spend threshold coins cannot be negative")
}
if err := validateDynamicPayoutCaps(config, false); err != nil {
return err
}
if err := validateDynamicStageThresholds(stages, config.Enabled); err != nil {
return err
}
if !config.Enabled {
// disabled 草稿允许金额口径仍为 0启用前必须由各 App 按自身业务口径和风控预算显式补齐。
return nil
}
if config.JackpotSpendThresholdCoins <= 0 {
return xerr.New(xerr.InvalidArgument, "dynamic_v3 jackpot cumulative spend threshold coins must be configured as a positive amount before enabling")
}
return validateDynamicPayoutCaps(config, true)
}
func validateJackpotMultipliers(multiplierPPMs []int64) error {
if len(multiplierPPMs) == 0 {
return xerr.New(xerr.InvalidArgument, "dynamic_v3 must configure at least one jackpot multiplier")
}
var previous int64
for _, multiplierPPM := range multiplierPPMs {
if multiplierPPM <= ppmScale || multiplierPPM <= previous {
return xerr.New(xerr.InvalidArgument, "dynamic_v3 jackpot multipliers must exceed 1x and be strictly increasing")
}
previous = multiplierPPM
}
return nil
}
func validateDynamicStageThresholds(stages map[string]domain.RuleStage, requireConfigured bool) error {
novice := stages[domain.StageNovice]
normal := stages[domain.StageNormal]
advanced := stages[domain.StageAdvanced]
if novice.MinRecharge7DCoins != 0 || novice.MinRecharge30DCoins != 0 {
return xerr.New(xerr.InvalidArgument, "dynamic_v3 novice recharge thresholds must stay 0/0 as protocol and storage sentinels")
}
if normal.MinRecharge7DCoins < 0 || normal.MinRecharge30DCoins < 0 || advanced.MinRecharge7DCoins < 0 || advanced.MinRecharge30DCoins < 0 {
return xerr.New(xerr.InvalidArgument, "dynamic_v3 recharge thresholds cannot be negative")
}
thresholdsUnset := normal.MinRecharge7DCoins == 0 && normal.MinRecharge30DCoins == 0 &&
advanced.MinRecharge7DCoins == 0 && advanced.MinRecharge30DCoins == 0
if !requireConfigured && thresholdsUnset {
// 未启用版本只在 normal/advanced 四个值全部为 0 时视为“纯未配置”草稿。
// 一旦运营开始填写任一门槛,即使仍 disabled 也必须保持完整单调边界,避免保存一份无法继续发布的半成品。
return nil
}
if normal.MinRecharge7DCoins == 0 && normal.MinRecharge30DCoins == 0 {
return xerr.New(xerr.InvalidArgument, "dynamic_v3 normal stage must configure a positive 7-day or 30-day recharge threshold before enabling")
}
if advanced.MinRecharge7DCoins < normal.MinRecharge7DCoins || advanced.MinRecharge30DCoins < normal.MinRecharge30DCoins ||
(advanced.MinRecharge7DCoins == normal.MinRecharge7DCoins && advanced.MinRecharge30DCoins == normal.MinRecharge30DCoins) {
return xerr.New(xerr.InvalidArgument, "dynamic_v3 advanced recharge thresholds must be component-wise monotonic and exceed normal in at least one dimension")
}
return nil
}
func validateDynamicPayoutCaps(config domain.RuleConfig, requirePositive bool) error {
caps := []struct {
name string
value int64
}{
{name: "max_single_payout", value: config.MaxSinglePayout},
{name: "user_hourly_payout_cap", value: config.UserHourlyPayoutCap},
{name: "user_daily_payout_cap", value: config.UserDailyPayoutCap},
{name: "device_daily_payout_cap", value: config.DeviceDailyPayoutCap},
{name: "room_hourly_payout_cap", value: config.RoomHourlyPayoutCap},
{name: "anchor_daily_payout_cap", value: config.AnchorDailyPayoutCap},
}
for _, cap := range caps {
if cap.value < 0 || (requirePositive && cap.value == 0) {
if !requirePositive {
return xerr.New(xerr.InvalidArgument, fmt.Sprintf("dynamic_v3 %s cannot be negative", cap.name))
}
return xerr.New(xerr.InvalidArgument, fmt.Sprintf("dynamic_v3 %s must be positive before enabling", cap.name))
}
}
return nil return nil
} }

View File

@ -0,0 +1,136 @@
package luckygift
import (
"strings"
"testing"
domain "hyapp/services/lucky-gift-service/internal/domain/luckygift"
)
func TestDefaultRuleConfigUsesSafeDynamicV3Draft(t *testing.T) {
config := DefaultRuleConfig("lalu", "lucky")
if config.StrategyVersion != domain.StrategyDynamicV3 || config.Enabled {
t.Fatalf("default strategy=%q enabled=%v, want disabled dynamic_v3 draft", config.StrategyVersion, config.Enabled)
}
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 != 0 || config.LossStreakGuarantee != 5 {
t.Fatalf("default control values mismatch: %+v", config)
}
if config.LowWatermarkCoins != 10_000_000 || config.LowWaterNonzeroFactorPPM != 700_000 ||
config.HighWatermarkCoins != 20_000_000 || config.HighWaterNonzeroFactorPPM != 1_300_000 {
t.Fatalf("default water controls mismatch: %+v", config)
}
if config.RechargeBoostWindowMS != 300_000 || config.RechargeBoostFactorPPM != 1_100_000 {
t.Fatalf("default recharge boost mismatch: %+v", config)
}
if config.JackpotGlobalRTPMaxPPM != 980_000 || config.JackpotUserDayRTPMaxPPM != 960_000 ||
config.JackpotUser72hRTPMaxPPM != 960_000 || config.MaxJackpotHitsPerUserDay != 5 {
t.Fatalf("default jackpot controls mismatch: %+v", config)
}
if config.JackpotSpendThresholdCoins != 0 || config.MaxSinglePayout != 0 || config.AnchorDailyPayoutCap != 0 {
t.Fatalf("App-specific monetary limits must stay unset in disabled draft: %+v", config)
}
wantThresholds := [][2]int64{{0, 0}, {0, 0}, {0, 0}}
for index, stage := range config.Stages {
if got := [2]int64{stage.MinRecharge7DCoins, stage.MinRecharge30DCoins}; got != wantThresholds[index] {
t.Fatalf("stage %s thresholds=%v, want %v", stage.Stage, got, wantThresholds[index])
}
var expectedRTPPPM int64
for _, tier := range stage.Tiers {
if tier.Enabled {
expectedRTPPPM += tier.MultiplierPPM * tier.BaseWeightPPM / ppmScale
}
}
if expectedRTPPPM != 980_000 {
t.Fatalf("stage %s static expected RTP=%d, want safe 980000", stage.Stage, expectedRTPPPM)
}
}
if err := validateRuleConfig(config); err != nil {
t.Fatalf("disabled dynamic_v3 draft should validate before App-specific monetary limits are known: %v", err)
}
config.Enabled = true
if err := validateRuleConfig(config); err == nil || !strings.Contains(err.Error(), "normal stage") {
t.Fatalf("enabled draft error=%v, want explicit normal recharge threshold guidance", err)
}
}
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.Stages[1].MinRecharge7DCoins = 100
config.Stages[1].MinRecharge30DCoins = 500
config.Stages[2].MinRecharge7DCoins = 500
config.Stages[2].MinRecharge30DCoins = 2_000
config.Enabled = true
if err := validateRuleConfig(config); err == nil || !strings.Contains(err.Error(), "cumulative spend threshold") {
t.Fatalf("enabled draft error=%v, want explicit cumulative spend coin threshold guidance", err)
}
config.JackpotSpendThresholdCoins = 5_000
config.MaxSinglePayout = 10_000
config.UserHourlyPayoutCap = 20_000
config.UserDailyPayoutCap = 30_000
config.DeviceDailyPayoutCap = 40_000
config.RoomHourlyPayoutCap = 50_000
config.AnchorDailyPayoutCap = 60_000
if err := validateRuleConfig(config); err != nil {
t.Fatalf("fully configured dynamic_v3 should validate: %v", err)
}
config.RoomHourlyPayoutCap = 0
if err := validateRuleConfig(config); err == nil || !strings.Contains(err.Error(), "room_hourly_payout_cap") {
t.Fatalf("zero six-dimensional cap error=%v, want the missing cap name", err)
}
}
func TestValidateDynamicV3RechargeStagesAreStrictlyMonotonic(t *testing.T) {
config := DefaultRuleConfig("lalu", "lucky")
config.Stages[1].MinRecharge7DCoins = 100
config.Stages[1].MinRecharge30DCoins = 500
config.Stages[2].MinRecharge7DCoins = 500
config.Stages[2].MinRecharge30DCoins = 2_000
config.Stages[2].MinRecharge7DCoins = config.Stages[1].MinRecharge7DCoins
config.Stages[2].MinRecharge30DCoins = config.Stages[1].MinRecharge30DCoins
if err := validateRuleConfig(config); err == nil || !strings.Contains(err.Error(), "exceed normal") {
t.Fatalf("equal normal/advanced thresholds error=%v, want strict stage separation", err)
}
}
func TestValidateRuleConfigKeepsLegacyFixedV2Compatible(t *testing.T) {
config := DefaultRuleConfig("lalu", "lucky")
config.StrategyVersion = ""
config.Enabled = true
// 升级前请求没有任何 dynamic_v3 字段;空 strategy_version 必须按 fixed_v2 解释,不能要求动态消费门槛或六维 cap。
config.ProfitRatePPM = 0
config.AnchorRatePPM = 0
config.InitialPoolCoins = 0
config.LossStreakGuarantee = 0
config.LowWatermarkCoins = 0
config.LowWaterNonzeroFactorPPM = 0
config.HighWatermarkCoins = 0
config.HighWaterNonzeroFactorPPM = 0
config.RechargeBoostWindowMS = 0
config.RechargeBoostFactorPPM = 0
config.JackpotMultiplierPPMs = nil
config.JackpotGlobalRTPMaxPPM = 0
config.JackpotUserDayRTPMaxPPM = 0
config.JackpotUser72hRTPMaxPPM = 0
config.MaxJackpotHitsPerUserDay = 0
if err := validateRuleConfig(config); err != nil {
t.Fatalf("legacy fixed_v2 config was broken by dynamic validation: %v", err)
}
normalized := normalizeRuleConfig(config)
if normalized.StrategyVersion != domain.StrategyFixedV2 {
t.Fatalf("legacy empty strategy normalized to %q, want fixed_v2", normalized.StrategyVersion)
}
}

View File

@ -43,6 +43,51 @@ func TestNormalizeDrawCommandRejectsOversizedInternalGiftWork(t *testing.T) {
if _, err := svc.normalizeDrawCommand(oversizedID); err == nil { if _, err := svc.normalizeDrawCommand(oversizedID); err == nil {
t.Fatal("command_id above 128 bytes was accepted") t.Fatal("command_id above 128 bytes was accepted")
} }
normalized, err := svc.normalizeDrawCommand(base)
if err != nil {
t.Fatalf("normalize valid command: %v", err)
}
if normalized.PaidAtMS != 0 {
t.Fatalf("service layer invented wallet paid_at_ms: %d", normalized.PaidAtMS)
}
}
func TestNormalizeDrawCommandKeepsLegacyMissingDeviceForRuleAwareValidation(t *testing.T) {
svc := &Service{}
normalized, err := svc.normalizeDrawCommand(domain.DrawCommand{
CommandID: "cmd-fixed-legacy-device", UserID: 1, TargetUserID: 2,
RoomID: "room-1", AnchorID: "2", GiftID: "rose", GiftCount: 1, CoinSpent: 10,
})
if err != nil || normalized.DeviceID != "" {
// normalize 尚未读到不可变规则;此处拒绝会破坏 fixed_v2 存量 JWT/command log 重放。
// 真正的 dynamic_v3 空设备拒绝在 MySQL 规则事务入口完成。
t.Fatalf("legacy missing device normalization got=%+v err=%v", normalized, err)
}
}
func TestNormalizeExternalDrawCommandRejectsMoreThan999SequentialDraws(t *testing.T) {
svc := &Service{}
_, err := svc.normalizeExternalDrawCommand(domain.ExternalDrawCommand{
AppCode: "aslan", ExternalUserID: "u-1", RequestID: "req-1",
GiftCount: 1_000, UnitAmount: 1, TotalAmount: 1_000, Currency: "COIN", PaidAtMS: 1,
})
if !xerr.IsCode(err, xerr.InvalidArgument) {
t.Fatalf("external gift_count=1000 should be rejected, got %v", err)
}
}
func TestNormalizeExternalDrawCommandDoesNotInventPaidAt(t *testing.T) {
svc := &Service{}
normalized, err := svc.normalizeExternalDrawCommand(domain.ExternalDrawCommand{
AppCode: "aslan", ExternalUserID: "u-1", RequestID: "req-paid-at",
GiftCount: 1, UnitAmount: 10, TotalAmount: 10, Currency: "COIN",
})
if err != nil {
t.Fatalf("normalize external draw: %v", err)
}
if normalized.PaidAtMS != 0 {
t.Fatalf("service layer invented external paid_at_ms: %d", normalized.PaidAtMS)
}
} }
func TestDrawBatchRejectsAggregateWorkAcrossTargets(t *testing.T) { func TestDrawBatchRejectsAggregateWorkAcrossTargets(t *testing.T) {

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" "strings"
"sync" "sync"
"time" "time"
"unicode/utf8"
"hyapp/pkg/appcode" "hyapp/pkg/appcode"
"hyapp/pkg/giftlimits" "hyapp/pkg/giftlimits"
@ -37,6 +38,7 @@ type Repository interface {
ListLuckyGiftDraws(ctx context.Context, query domain.DrawQuery) ([]domain.DrawResult, int64, error) ListLuckyGiftDraws(ctx context.Context, query domain.DrawQuery) ([]domain.DrawResult, int64, error)
GetLuckyGiftDrawSummary(ctx context.Context, query domain.DrawQuery) (domain.DrawSummary, error) GetLuckyGiftDrawSummary(ctx context.Context, query domain.DrawQuery) (domain.DrawSummary, error)
ListLuckyGiftPoolBalances(ctx context.Context, query domain.PoolBalanceQuery) ([]domain.PoolBalance, 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) 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) 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) DeleteDeliveredLuckyGiftOutboxBefore(ctx context.Context, cutoffMS int64, limit int) (int64, error)
@ -184,6 +186,7 @@ func (s *Service) ExecuteExternalDraw(ctx context.Context, cmd domain.ExternalDr
func (s *Service) normalizeExternalDrawCommand(cmd domain.ExternalDrawCommand) (domain.ExternalDrawCommand, error) { func (s *Service) normalizeExternalDrawCommand(cmd domain.ExternalDrawCommand) (domain.ExternalDrawCommand, error) {
cmd.AppCode = appcode.Normalize(cmd.AppCode) cmd.AppCode = appcode.Normalize(cmd.AppCode)
cmd.ExternalUserID = strings.TrimSpace(cmd.ExternalUserID) cmd.ExternalUserID = strings.TrimSpace(cmd.ExternalUserID)
cmd.DeviceID = strings.TrimSpace(cmd.DeviceID)
cmd.RequestID = strings.TrimSpace(cmd.RequestID) cmd.RequestID = strings.TrimSpace(cmd.RequestID)
cmd.Currency = strings.ToUpper(strings.TrimSpace(cmd.Currency)) cmd.Currency = strings.ToUpper(strings.TrimSpace(cmd.Currency))
cmd.MetadataJSON = strings.TrimSpace(cmd.MetadataJSON) cmd.MetadataJSON = strings.TrimSpace(cmd.MetadataJSON)
@ -191,15 +194,17 @@ func (s *Service) normalizeExternalDrawCommand(cmd domain.ExternalDrawCommand) (
if cmd.AppCode == "" || cmd.ExternalUserID == "" || cmd.RequestID == "" { if cmd.AppCode == "" || cmd.ExternalUserID == "" || cmd.RequestID == "" {
return domain.ExternalDrawCommand{}, xerr.New(xerr.InvalidArgument, "external lucky gift identity is incomplete") return domain.ExternalDrawCommand{}, xerr.New(xerr.InvalidArgument, "external lucky gift identity is incomplete")
} }
if len(cmd.AppCode) > 32 || len(cmd.ExternalUserID) > 128 || len(cmd.RequestID) > 128 || len(cmd.PoolID) > 96 { if len(cmd.AppCode) > 32 || len(cmd.ExternalUserID) > 128 || len(cmd.DeviceID) > 128 || len(cmd.RequestID) > 128 || len(cmd.PoolID) > 96 {
// 外部请求的 app 级业务键会进入唯一键和审计列;长度先在服务层失败,避免事务中途暴露 MySQL truncation/too long 错误。 // 外部请求的 app 级业务键会进入唯一键和审计列;长度先在服务层失败,避免事务中途暴露 MySQL truncation/too long 错误。
return domain.ExternalDrawCommand{}, xerr.New(xerr.InvalidArgument, "external lucky gift identity is too long") return domain.ExternalDrawCommand{}, xerr.New(xerr.InvalidArgument, "external lucky gift identity is too long")
} }
if cmd.GiftCount <= 0 || cmd.UnitAmount <= 0 || cmd.TotalAmount <= 0 { if cmd.GiftCount <= 0 || cmd.UnitAmount <= 0 || cmd.TotalAmount <= 0 {
return domain.ExternalDrawCommand{}, xerr.New(xerr.InvalidArgument, "external lucky gift amount must be positive") return domain.ExternalDrawCommand{}, xerr.New(xerr.InvalidArgument, "external lucky gift amount must be positive")
} }
if cmd.GiftCount > int64(^uint32(0)>>1) { // dynamic_v3 必须为每个礼物保存一条顺序子抽事实;与内部送礼统一限制 999
return domain.ExternalDrawCommand{}, xerr.New(xerr.InvalidArgument, "external lucky gift count is too large") // 避免外部请求用一个超大 count 制造不可控事务和审计行数。
if cmd.GiftCount > 999 {
return domain.ExternalDrawCommand{}, xerr.New(xerr.InvalidArgument, "external lucky gift count cannot exceed 999")
} }
if cmd.GiftCount > (1<<63-1)/cmd.UnitAmount || cmd.GiftCount*cmd.UnitAmount != cmd.TotalAmount { if cmd.GiftCount > (1<<63-1)/cmd.UnitAmount || cmd.GiftCount*cmd.UnitAmount != cmd.TotalAmount {
// 金额校验必须在 owner service 再做一遍gateway 是外部入口,不能成为唯一可信校验点。 // 金额校验必须在 owner service 再做一遍gateway 是外部入口,不能成为唯一可信校验点。
@ -218,10 +223,8 @@ func (s *Service) normalizeExternalDrawCommand(cmd domain.ExternalDrawCommand) (
if cmd.MetadataJSON != "" && !json.Valid([]byte(cmd.MetadataJSON)) { if cmd.MetadataJSON != "" && !json.Valid([]byte(cmd.MetadataJSON)) {
return domain.ExternalDrawCommand{}, xerr.New(xerr.InvalidArgument, "external lucky gift metadata_json is invalid") return domain.ExternalDrawCommand{}, xerr.New(xerr.InvalidArgument, "external lucky gift metadata_json is invalid")
} }
if cmd.PaidAtMS <= 0 { // paid_at_ms 是否可兼容缺失取决于不可变规则版本service 层保留原值repository
// paid_at_ms 表示外部 App 扣费完成时间;缺失时用 UTC 服务端时间兜底,不能使用本地时区。 // 读取 fixed_v2 后才可使用 nowMSdynamic_v3 内外调用都必须提供真实扣费时间。
cmd.PaidAtMS = s.now().UTC().UnixMilli()
}
return cmd, nil return cmd, nil
} }
@ -237,9 +240,14 @@ func (s *Service) normalizeDrawCommand(cmd domain.DrawCommand) (domain.DrawComma
cmd.SenderAvatar = strings.TrimSpace(cmd.SenderAvatar) cmd.SenderAvatar = strings.TrimSpace(cmd.SenderAvatar)
cmd.SenderDisplayUserID = strings.TrimSpace(cmd.SenderDisplayUserID) cmd.SenderDisplayUserID = strings.TrimSpace(cmd.SenderDisplayUserID)
cmd.SenderPrettyDisplayUserID = strings.TrimSpace(cmd.SenderPrettyDisplayUserID) cmd.SenderPrettyDisplayUserID = strings.TrimSpace(cmd.SenderPrettyDisplayUserID)
if !giftlimits.ValidCommandID(cmd.CommandID) || cmd.UserID <= 0 || cmd.DeviceID == "" || cmd.RoomID == "" || cmd.AnchorID == "" || cmd.GiftID == "" || cmd.CoinSpent <= 0 { if !giftlimits.ValidCommandID(cmd.CommandID) || cmd.UserID <= 0 || cmd.RoomID == "" || cmd.AnchorID == "" || cmd.GiftID == "" || cmd.CoinSpent <= 0 {
return domain.DrawCommand{}, xerr.New(xerr.InvalidArgument, "lucky gift draw command is incomplete") return domain.DrawCommand{}, xerr.New(xerr.InvalidArgument, "lucky gift draw command is incomplete")
} }
if len(cmd.DeviceID) > 128 {
return domain.DrawCommand{}, xerr.New(xerr.InvalidArgument, "lucky gift draw device_id is too long")
}
// 这一层不能把空 device_id 一概拒绝:存量 fixed_v2 请求/恢复命令没有该字段。
// 仓储读到不可变规则后才能对 dynamic_v3 做权威 fail-close期间不用 session/command 兜底。
if cmd.TargetUserID < 0 { if cmd.TargetUserID < 0 {
return domain.DrawCommand{}, xerr.New(xerr.InvalidArgument, "lucky gift draw metadata is invalid") return domain.DrawCommand{}, xerr.New(xerr.InvalidArgument, "lucky gift draw metadata is invalid")
} }
@ -248,10 +256,8 @@ func (s *Service) normalizeDrawCommand(cmd domain.DrawCommand) (domain.DrawComma
// The cap prevents a direct internal caller from allocating unbounded per-unit RNG/write work. // The cap prevents a direct internal caller from allocating unbounded per-unit RNG/write work.
return domain.DrawCommand{}, xerr.New(xerr.InvalidArgument, "lucky gift count exceeds command limit") return domain.DrawCommand{}, xerr.New(xerr.InvalidArgument, "lucky gift count exceeds command limit")
} }
if cmd.PaidAtMS <= 0 { // 内部 wallet paid_at_ms 不能在 service 层用当前时钟补齐repository 必须先读取策略,
// paid_at_ms 是扣费事实时间,缺失时用 UTC 服务端时间补齐,避免本地时区污染 RTP 窗口。 // fixed_v2 才允许兼容降级dynamic_v3 则严格拒绝缺失 owner 事实。
cmd.PaidAtMS = s.now().UTC().UnixMilli()
}
return cmd, nil return cmd, nil
} }
@ -776,9 +782,57 @@ func (s *Service) ListPoolBalances(ctx context.Context, query domain.PoolBalance
if strings.TrimSpace(query.PoolID) != "" { if strings.TrimSpace(query.PoolID) != "" {
query.PoolID = normalizePoolID(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) 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 { func (s *Service) requireRepository() error {
if s == nil || s.repository == nil { if s == nil || s.repository == nil {
return xerr.New(xerr.Unavailable, "lucky gift repository is not configured") return xerr.New(xerr.Unavailable, "lucky gift repository is not configured")

View File

@ -48,9 +48,16 @@ func externalLuckyScopeID(appCode string) string {
return externalLuckyHashScope("external", appcode.Normalize(appCode)) return externalLuckyHashScope("external", appcode.Normalize(appCode))
} }
func externalLuckyDeviceID(appCode string, externalUserID string) string { func externalLuckyDeviceID(appCode string, deviceID string) string {
// 外部用户 ID 最长可到 128 字符hash 后写入审计字段,避免长字符串碰到内部设备字段长度边界。 // 外部设备 ID 最长可到 128 字符hash 后写入内部审计/风控字段,
return externalLuckyHashScope("external_device", appcode.Normalize(appCode), externalUserID) // 既保持同 App+同设备的稳定 scope也不把外部原始标识暴露到 draw 事实。
return externalLuckyHashScope("external_device", appcode.Normalize(appCode), strings.TrimSpace(deviceID))
}
func externalLuckyFixedCompatibleDeviceID(cmd domain.ExternalDrawCommand) string {
// fixed_v2 不解释新 device_id始终保留历史 app+external_user_id 作用域。
// 只有 dynamic_v3 才使用调用方绑定的设备,避免滚动升级改写 fixed 抽奖审计语义。
return externalLuckyDeviceID(cmd.AppCode, cmd.ExternalUserID)
} }
func externalLuckyAnchorID(appCode string) string { func externalLuckyAnchorID(appCode string) string {
@ -75,6 +82,9 @@ func (r *Repository) ExecuteExternalGiftDraw(ctx context.Context, cmd domain.Ext
} }
defer func() { _ = tx.Rollback() }() defer func() { _ = tx.Rollback() }()
if err := r.lockExternalLuckyGiftRequest(ctx, tx, cmd.AppCode, cmd.RequestID, nowMS); err != nil {
return domain.ExternalDrawResult{}, err
}
if existing, ok, err := r.getExternalGiftDrawForUpdate(ctx, tx, cmd.AppCode, cmd.RequestID); err != nil || ok { if existing, ok, err := r.getExternalGiftDrawForUpdate(ctx, tx, cmd.AppCode, cmd.RequestID); err != nil || ok {
if err != nil { if err != nil {
return domain.ExternalDrawResult{}, err return domain.ExternalDrawResult{}, err
@ -88,6 +98,16 @@ func (r *Repository) ExecuteExternalGiftDraw(ctx context.Context, cmd domain.Ext
if err != nil { if err != nil {
return domain.ExternalDrawResult{}, err return domain.ExternalDrawResult{}, err
} }
if exists && rule.StrategyVersion == domain.StrategyDynamicV3 {
if cmd.PaidAtMS <= 0 {
// 外部 dynamic_v3 同样只能消费调用方已完成扣费的事实时间;请求到达 lucky 的时间
// 不是支付时间不能用于充值短窗、UTC 日/小时或精确 72h 风控归属。
return domain.ExternalDrawResult{}, xerr.New(xerr.InvalidArgument, "external dynamic lucky gift paid_at_ms is required")
}
} else if cmd.PaidAtMS <= 0 {
// 未配置规则和历史 fixed_v2 保留滚动发布兼容dynamic_v3 永远不会进入这条降级。
cmd.PaidAtMS = nowMS
}
selected := externalDrawCandidate{TierID: "disabled", MultiplierPPM: 0} selected := externalDrawCandidate{TierID: "disabled", MultiplierPPM: 0}
rewardAmount := int64(0) rewardAmount := int64(0)
ruleVersion := rule.RuleVersion ruleVersion := rule.RuleVersion
@ -159,7 +179,27 @@ func (r *Repository) ExecuteExternalGiftDraw(ctx context.Context, cmd domain.Ext
return externalDrawResultFromRow(row), nil return externalDrawResultFromRow(row), nil
} }
// lockExternalLuckyGiftRequest 为“尚不存在结果行”的并发幂等补上可锁主键。
// SELECT ... FOR UPDATE 无法锁住不存在的 external draw先幂等建锁行后第二个同 request_id 请求会等待首事务提交,再读回同一结果。
func (r *Repository) lockExternalLuckyGiftRequest(ctx context.Context, tx *sql.Tx, appCode, requestID string, nowMS int64) error {
if _, err := tx.ExecContext(ctx, `
INSERT INTO external_lucky_gift_request_locks (app_code, request_id, created_at_ms)
VALUES (?, ?, ?)
ON DUPLICATE KEY UPDATE created_at_ms = created_at_ms`, appCode, requestID, nowMS,
); err != nil {
return err
}
var lockedRequestID string
return tx.QueryRowContext(ctx, `
SELECT request_id FROM external_lucky_gift_request_locks
WHERE app_code = ? AND request_id = ? FOR UPDATE`, appCode, requestID,
).Scan(&lockedRequestID)
}
func (r *Repository) executeExternalGiftEconomy(ctx context.Context, tx *sql.Tx, cmd domain.ExternalDrawCommand, rule domain.RuleConfig, nowMS int64) (externalDrawCandidate, int64, error) { func (r *Repository) executeExternalGiftEconomy(ctx context.Context, tx *sql.Tx, cmd domain.ExternalDrawCommand, rule domain.RuleConfig, nowMS int64) (externalDrawCandidate, int64, error) {
if rule.StrategyVersion == domain.StrategyDynamicV3 {
return r.executeExternalDynamicGiftEconomy(ctx, tx, cmd, rule, nowMS)
}
baseConfig, err := luckyRuntimeConfigFromRuleConfig(rule) baseConfig, err := luckyRuntimeConfigFromRuleConfig(rule)
if err != nil { if err != nil {
return externalDrawCandidate{}, 0, err return externalDrawCandidate{}, 0, err
@ -172,7 +212,7 @@ func (r *Repository) executeExternalGiftEconomy(ctx context.Context, tx *sql.Tx,
CommandID: "external:" + cmd.AppCode + ":" + cmd.RequestID, CommandID: "external:" + cmd.AppCode + ":" + cmd.RequestID,
PoolID: cmd.PoolID, PoolID: cmd.PoolID,
UserID: externalUserID, UserID: externalUserID,
DeviceID: externalLuckyDeviceID(cmd.AppCode, cmd.ExternalUserID), DeviceID: externalLuckyFixedCompatibleDeviceID(cmd),
RoomID: roomScopeID, RoomID: roomScopeID,
AnchorID: externalLuckyAnchorID(cmd.AppCode), AnchorID: externalLuckyAnchorID(cmd.AppCode),
GiftID: config.GiftID, GiftID: config.GiftID,
@ -218,6 +258,66 @@ func (r *Repository) executeExternalGiftEconomy(ctx context.Context, tx *sql.Tx,
return externalDrawCandidate{TierID: candidate.TierID, MultiplierPPM: candidate.MultiplierPPM}, candidate.effectiveReward(), nil return externalDrawCandidate{TierID: candidate.TierID, MultiplierPPM: candidate.MultiplierPPM}, candidate.effectiveReward(), nil
} }
// executeExternalDynamicGiftEconomy 让外部 App 复用与房间送礼完全相同的 dynamic_v3 内核。
// 外部调用方自己负责余额和主播收益结算,因此本服务按配置拆账、返回聚合 reward且只写 external 子抽审计,绝不生成 HyApp wallet outbox。
func (r *Repository) executeExternalDynamicGiftEconomy(ctx context.Context, tx *sql.Tx, cmd domain.ExternalDrawCommand, rule domain.RuleConfig, nowMS int64) (externalDrawCandidate, int64, error) {
trustedDeviceID, err := normalizeLuckyDynamicDeviceID(cmd.DeviceID)
if err != nil {
// luck-gateway 目前无法独立验证外部设备dynamic_v3 至少强制调用方提供经其认证/签名绑定的稳定值。
// 缺失时不得用 external_user_id/request_id 伪造设备 scope。
return externalDrawCandidate{}, 0, err
}
config, err := luckyRuntimeConfigFromRuleConfig(rule)
if err != nil {
return externalDrawCandidate{}, 0, err
}
strategyConfig := luckyDynamicStrategyConfig(config)
split, err := domain.SplitLuckyGiftStrategyFunds(cmd.TotalAmount, strategyConfig)
if err != nil {
return externalDrawCandidate{}, 0, err
}
externalUserID := externalLuckyUserID(cmd.AppCode, cmd.ExternalUserID)
drawCommand := domain.DrawCommand{
CommandID: "external:" + cmd.AppCode + ":" + cmd.RequestID,
PoolID: config.PoolID,
UserID: externalUserID,
DeviceID: externalLuckyDeviceID(cmd.AppCode, trustedDeviceID),
RoomID: externalLuckyScopeID(cmd.AppCode),
AnchorID: externalLuckyAnchorID(cmd.AppCode),
GiftID: config.GiftID,
GiftCount: int32(cmd.GiftCount),
CoinSpent: cmd.TotalAmount,
PaidAtMS: cmd.PaidAtMS,
GiftIncomeCoins: split.AnchorReturnCoins,
}
// 当前外部协议没有可验证的充值 owner 快照;保持 0/0 会落 novice 且关闭五分钟加成,
// 比从 metadata 或客户端自报字段猜测更安全。后续若扩协议,必须把签名覆盖的充值事实显式入契约。
ctx = appcode.WithContext(ctx, cmd.AppCode)
results, err := r.executeDynamicLuckyGiftDrawBatch(ctx, tx, drawCommand, int32(cmd.GiftCount), rule, luckyDynamicPersistence{
ExternalRequestID: cmd.RequestID,
ExternalUserID: cmd.ExternalUserID,
}, nowMS)
if err != nil {
return externalDrawCandidate{}, 0, err
}
var reward int64
for _, result := range results {
if result.EffectiveRewardCoins < 0 || reward > int64(^uint64(0)>>1)-result.EffectiveRewardCoins {
return externalDrawCandidate{}, 0, xerr.New(xerr.Internal, "external dynamic lucky gift reward overflow")
}
reward += result.EffectiveRewardCoins
}
candidate := externalDrawCandidate{TierID: "dynamic_v3_batch"}
if len(results) == 1 {
candidate.TierID = results[0].SelectedTierID
candidate.MultiplierPPM = results[0].MultiplierPPM
} else if ratio, valid := (domain.StrategyRTP{WagerCoins: cmd.TotalAmount, PayoutCoins: reward}).RatioPPM(); valid {
// 批量可能命中多个不同倍率;聚合 multiplier 仅表达 reward/total逐抽真实倍率保存在 item 表。
candidate.MultiplierPPM = ratio
}
return candidate, reward, nil
}
func (r *Repository) applyExternalLuckyEconomy(ctx context.Context, tx *sql.Tx, appCode string, config domain.Config, cmd domain.DrawCommand, candidate luckyCandidate, globalWindow luckyRTPWindow, giftWindow luckyRTPWindow, basePool luckyPool, nextCumulativeWager int64, nextEquivalentDraws int64, nowMS int64) error { func (r *Repository) applyExternalLuckyEconomy(ctx context.Context, tx *sql.Tx, appCode string, config domain.Config, cmd domain.DrawCommand, candidate luckyCandidate, globalWindow luckyRTPWindow, giftWindow luckyRTPWindow, basePool luckyPool, nextCumulativeWager int64, nextEquivalentDraws int64, nowMS int64) error {
if _, err := tx.ExecContext(ctx, ` if _, err := tx.ExecContext(ctx, `
UPDATE lucky_rtp_windows UPDATE lucky_rtp_windows

View File

@ -0,0 +1,456 @@
package mysql
import (
"context"
"database/sql"
"fmt"
"strings"
"hyapp/pkg/appcode"
"hyapp/pkg/idgen"
"hyapp/pkg/xerr"
domain "hyapp/services/lucky-gift-service/internal/domain/luckygift"
)
type luckyDynamicBatchState struct {
PublicIn int64
ProfitIn int64
AnchorIn int64
Payout int64
DrawIDs []string
Records []luckyDrawRecordInput
Results []domain.DrawResult
BasePool luckyPool
GlobalWindow luckyRTPWindow
GiftWindow luckyRTPWindow
// *Windows 保存本批已经滚满的窗口;当前窗口单独保留,事务尾统一按绝对快照写回。
GlobalWindows []luckyRTPWindow
GiftWindows []luckyRTPWindow
UserState luckyUserState
RiskCounters map[string]luckyDynamicRiskCounter
DayState luckyUserStrategyDay
HourState luckyUserRTPHour
Strategy domain.StrategyState
}
// luckyDynamicPersistence 把内部钱包结算与外部 App 自行结算区分开。
// 外部模式仍推进同一套奖池/RTP/风控状态并保留 N 条子抽审计,但不能生成 HyApp wallet 返奖 outbox。
type luckyDynamicPersistence struct {
ExternalRequestID string
ExternalUserID string
}
func (p luckyDynamicPersistence) external() bool {
return p.ExternalRequestID != "" || p.ExternalUserID != ""
}
// executeDynamicLuckyGiftDrawBatch 在已经锁定规则、完成 command_id 幂等探测后执行 dynamic_v3。
// 所有共享行只锁一次gift_count=N 在内存里严格推进 N 次,事务尾再批量落 draw facts 和聚合钱包 outbox。
func (r *Repository) executeDynamicLuckyGiftDrawBatch(ctx context.Context, tx *sql.Tx, cmd domain.DrawCommand, drawCount int32, rule domain.RuleConfig, persistence luckyDynamicPersistence, nowMS int64) ([]domain.DrawResult, error) {
if cmd.PaidAtMS <= 0 {
// dynamic_v3 的充值后五分钟、UTC 日/小时和精确 72h 都必须归属 wallet owner 的扣费事实时间。
// 外部调用也必须传它自己的扣费事实;缺失时绝不能用 lucky 收到请求或 room saga/recovery 时钟补齐。
return nil, xerr.New(xerr.InvalidArgument, "dynamic lucky gift paid_at_ms is required")
}
// 设备日额度是 dynamic_v3 的硬风控;只有显式的可信 device_id 才能进入事务状态。
// 不允许用 session_id/command_id 填充,否则换 token 或换命令即可重置设备日上限。
deviceID, err := normalizeLuckyDynamicDeviceID(cmd.DeviceID)
if err != nil {
return nil, err
}
cmd.DeviceID = deviceID
appCode := appcode.FromContext(ctx)
config, err := luckyRuntimeConfigFromRuleConfig(rule)
if err != nil {
return nil, err
}
if !luckyUsesDynamicStrategy(config) {
return nil, xerr.New(xerr.Internal, "dynamic lucky gift path received a fixed strategy")
}
strategyConfig := luckyDynamicStrategyConfig(config)
referencePrice := config.GiftPrice
userState, err := r.getLuckyUserStateForUpdate(ctx, tx, appCode, cmd.UserID, config.GiftID, nowMS)
if err != nil {
return nil, err
}
windowScopeID := luckyRuleWindowScopeID(config)
globalWindow, err := r.getOpenLuckyDynamicRTPWindow(ctx, tx, appCode, "pool", windowScopeID, config.SettlementWindowWager, config.GlobalWindowDraws, config.TargetRTPPPM, nowMS)
if err != nil {
return nil, err
}
giftWindow, err := r.getOpenLuckyDynamicRTPWindow(ctx, tx, appCode, "pool_gift", windowScopeID, config.SettlementWindowWager, config.GiftWindowDraws, config.TargetRTPPPM, nowMS)
if err != nil {
return nil, err
}
// dynamic_v3 永远从独立的零余额账本启动;运营启动资金必须走人工 credit不能由规则版本发布隐式注资。
basePool, err := r.getOrCreateLuckyPool(ctx, tx, appCode, luckyDynamicPoolScopeType, luckyBasePoolScopeID(config), 0, 0, nowMS)
if err != nil {
return nil, err
}
riskCounters, err := r.getLuckyDynamicRiskCounters(ctx, tx, appCode, config.PoolID, cmd, nowMS)
if err != nil {
return nil, err
}
dayState, err := r.getOrCreateLuckyUserStrategyDay(ctx, tx, appCode, config.PoolID, cmd.UserID, cmd, nowMS)
if err != nil {
return nil, err
}
hourState, err := r.getOrCreateLuckyUserRTPHour(ctx, tx, appCode, config.PoolID, cmd.UserID, cmd, nowMS)
if err != nil {
return nil, err
}
rolling72, err := r.getLuckyUserRolling72HourRTP(ctx, tx, appCode, config.PoolID, cmd.UserID, luckyPaidTime(cmd, nowMS).UnixMilli())
if err != nil {
return nil, err
}
fundSplit, err := luckyDynamicFundSplit(cmd.CoinSpent, cmd.GiftIncomeCoins, strategyConfig)
if err != nil {
return nil, err
}
unitSplits, err := luckyDynamicUnitFundSplits(cmd.CoinSpent, drawCount, fundSplit)
if err != nil {
return nil, err
}
state := luckyDynamicBatchState{
DrawIDs: make([]string, 0, drawCount),
Records: make([]luckyDrawRecordInput, 0, drawCount),
Results: make([]domain.DrawResult, 0, drawCount),
BasePool: basePool,
GlobalWindow: globalWindow,
GiftWindow: giftWindow,
GlobalWindows: make([]luckyRTPWindow, 0, 2),
GiftWindows: make([]luckyRTPWindow, 0, 2),
UserState: userState,
RiskCounters: riskCounters,
DayState: dayState,
HourState: hourState,
Strategy: domain.StrategyState{
PoolBalanceCoins: basePool.Balance,
ConsecutiveZeroDraws: userState.LossStreak,
PendingMilestoneTokens: userState.PendingJackpotTokens,
UserDailyJackpotWins: dayState.JackpotHits,
UserDaySpendCoins: dayState.SpendCoins,
GlobalRTP: domain.StrategyRTP{WagerCoins: globalWindow.WagerCoins, PayoutCoins: globalWindow.ActualPayoutCoins},
UserDayRTP: domain.StrategyRTP{WagerCoins: dayState.WagerCoins, PayoutCoins: dayState.PayoutCoins},
User72HourRTP: rolling72,
Version: userState.PaidDraws,
},
}
for index := int32(1); index <= drawCount; index++ {
// gift_count=N 的每一份都必须像独立命令一样跨 RTP 窗口;若上一抽正好填满,
// 本抽先创建下一窗口并让 mechanism1 使用新窗口 RTP不能等整批结束后才滚动。
if err := r.rollLuckyDynamicWindows(ctx, tx, appCode, config.SettlementWindowWager, &state, nowMS); err != nil {
return nil, err
}
unitCommand := cmd
unitCommand.CommandID = luckyDrawSubCommandID(cmd.CommandID, index, drawCount)
unitCommand.GiftCount = 1
unitCommand.CoinSpent = luckyDrawUnitSpend(cmd.CoinSpent, drawCount, index)
unitSplit := unitSplits[index-1]
unitCommand.GiftIncomeCoins = unitSplit.AnchorReturnCoins
if unitCommand.CoinSpent <= 0 {
return nil, xerr.New(xerr.InvalidArgument, "dynamic lucky gift unit spend must be positive")
}
if unitSplit.PublicPoolCoins+unitSplit.ProfitPoolCoins+unitSplit.AnchorReturnCoins != unitCommand.CoinSpent {
return nil, xerr.New(xerr.Internal, "dynamic lucky gift unit fund split is not conserved")
}
decision, err := domain.DecideLuckyGiftStrategy(strategyConfig, state.Strategy, domain.StrategyInput{
GiftPriceCoins: unitCommand.CoinSpent,
PoolContributionCoins: unitSplit.PublicPoolCoins,
NowMS: luckyPaidTime(unitCommand, nowMS).UnixMilli(),
HasRechargeFact: unitCommand.LastRechargedAtMS > 0,
LastRechargeAtMS: unitCommand.LastRechargedAtMS,
Recharge7DCoins: unitCommand.Recharge7DCoins,
Recharge30DCoins: unitCommand.Recharge30DCoins,
RiskCapacity: luckyDynamicRiskCapacity(config, state.RiskCounters),
}, luckyCryptoStrategyRandom{})
if err != nil {
return nil, err
}
if err := r.stageLuckyDynamicDecision(appCode, config, referencePrice, unitCommand, unitSplit, decision, nowMS, &state); err != nil {
return nil, err
}
}
if persistence.external() {
if persistence.ExternalRequestID == "" || persistence.ExternalUserID == "" {
return nil, xerr.New(xerr.InvalidArgument, "external dynamic lucky gift persistence identity is incomplete")
}
for index := range state.Records {
state.Records[index].RewardStatus = domain.StatusGranted
state.Results[index].RewardStatus = domain.StatusGranted
}
}
if err := r.persistLuckyDynamicBatch(ctx, tx, appCode, cmd, config, state, persistence, nowMS); err != nil {
return nil, err
}
return state.Results, nil
}
func normalizeLuckyDynamicDeviceID(value string) (string, error) {
value = strings.TrimSpace(value)
if value == "" {
return "", xerr.New(xerr.InvalidArgument, "dynamic lucky gift device_id is required")
}
if len(value) > 128 {
return "", xerr.New(xerr.InvalidArgument, "dynamic lucky gift device_id is too long")
}
return value, nil
}
func (r *Repository) rollLuckyDynamicWindows(ctx context.Context, tx *sql.Tx, appCode string, controlWager int64, state *luckyDynamicBatchState, nowMS int64) error {
if controlWager <= 0 {
return xerr.New(xerr.Internal, "dynamic lucky gift RTP window wager must be positive")
}
// 图中“结算窗口流水”是金币阈值,不是抽数。整笔单抽归属进入时所在窗口;本抽把流水推过阈值后,下一抽才开启新窗口,避免拆分一次中奖事实。
if state.GlobalWindow.WagerCoins >= controlWager {
closed := state.GlobalWindow
closed.Status = "closed"
state.GlobalWindows = append(state.GlobalWindows, closed)
next, err := r.createLuckyRTPWindow(
ctx, tx, appCode, closed.ScopeType, closed.ScopeID, closed.WindowIndex+1,
closed.CarryPPM, closed.ControlDraws, closed.TargetRTPPPM, nowMS,
)
if err != nil {
return err
}
state.GlobalWindow = next
// 大奖机制一只看当前结算窗口;窗口滚动后分母为 0必须先积累新样本不能沿用旧窗口资格。
state.Strategy.GlobalRTP = domain.StrategyRTP{
WagerCoins: next.WagerCoins,
PayoutCoins: next.ActualPayoutCoins,
}
}
if state.GiftWindow.WagerCoins >= controlWager {
closed := state.GiftWindow
closed.Status = "closed"
state.GiftWindows = append(state.GiftWindows, closed)
next, err := r.createLuckyRTPWindow(
ctx, tx, appCode, closed.ScopeType, closed.ScopeID, closed.WindowIndex+1,
closed.CarryPPM, closed.ControlDraws, closed.TargetRTPPPM, nowMS,
)
if err != nil {
return err
}
state.GiftWindow = next
}
return nil
}
func (r *Repository) stageLuckyDynamicDecision(appCode string, config domain.Config, referencePrice int64, cmd domain.DrawCommand, split domain.StrategyFundSplit, decision domain.StrategyDecision, nowMS int64, state *luckyDynamicBatchState) error {
state.PublicIn += split.PublicPoolCoins
state.ProfitIn += split.ProfitPoolCoins
state.AnchorIn += split.AnchorReturnCoins
state.Payout += decision.PayoutCoins
state.Strategy = decision.NextState
state.BasePool.Balance = decision.PoolAfterCoins
state.BasePool.TotalIn += split.PublicPoolCoins
state.BasePool.TotalOut += decision.PayoutCoins
state.GlobalWindow = state.GlobalWindow.withCurrentWager(cmd.CoinSpent)
state.GlobalWindow.PaidDraws++
state.GlobalWindow.ActualPayoutCoins += decision.PayoutCoins
state.GiftWindow = state.GiftWindow.withCurrentWager(cmd.CoinSpent)
state.GiftWindow.PaidDraws++
state.GiftWindow.ActualPayoutCoins += decision.PayoutCoins
state.UserState.PaidDraws++
state.UserState.CumulativeWagerCoins += cmd.CoinSpent
state.UserState.EquivalentDraws = luckyEquivalentDraws(state.UserState.CumulativeWagerCoins, referencePrice)
state.UserState.LossStreak = decision.NextState.ConsecutiveZeroDraws
state.UserState.PendingJackpotTokens = decision.NextState.PendingMilestoneTokens
state.DayState.WagerCoins = decision.NextState.UserDayRTP.WagerCoins
state.DayState.PayoutCoins = decision.NextState.UserDayRTP.PayoutCoins
state.DayState.SpendCoins = decision.NextState.UserDaySpendCoins
state.DayState.JackpotHits = decision.NextState.UserDailyJackpotWins
state.HourState.WagerCoins += cmd.CoinSpent
state.HourState.PayoutCoins += decision.PayoutCoins
for key, counter := range state.RiskCounters {
counter.Payout += decision.PayoutCoins
state.RiskCounters[key] = counter
}
candidate := luckyCandidate{
TierID: decision.SelectedTier.ID,
Pool: decision.Stage,
Weight: luckyDynamicSelectedWeight(decision),
BaseReward: decision.PayoutCoins,
MultiplierPPM: decision.SelectedTier.MultiplierPPM,
HighMultiplier: decision.Jackpot || decision.SelectedTier.MultiplierPPM >= 10_000_000,
}
drawID := idgen.New("lucky_draw")
rewardStatus := luckyInitialRewardStatus(candidate, false)
limited := luckyDynamicLimitedReasons(decision.Trace)
decisionCopy := decision
poolSnapshot := state.BasePool
globalSnapshot := state.GlobalWindow
giftSnapshot := state.GiftWindow
record := luckyDrawRecordInput{
AppCode: appCode,
DrawID: drawID,
Command: cmd,
Config: config,
ExperiencePool: decision.Stage,
Candidate: candidate,
Limited: limited,
GlobalWindow: globalSnapshot,
GiftWindow: giftSnapshot,
BasePool: poolSnapshot,
StrategyDecision: &decisionCopy,
FundSplit: split,
UserDayRTP: decision.NextState.UserDayRTP,
User72HourRTP: decision.NextState.User72HourRTP,
RewardStatus: rewardStatus,
NowMS: nowMS,
}
state.DrawIDs = append(state.DrawIDs, drawID)
state.Records = append(state.Records, record)
globalRTP, _ := decision.NextState.GlobalRTP.RatioPPM()
giftRTP := luckyRTPPPM(giftSnapshot.WagerCoins, giftSnapshot.ActualPayoutCoins)
state.Results = append(state.Results, domain.DrawResult{
DrawID: drawID,
CommandID: cmd.CommandID,
PoolID: config.PoolID,
GiftID: cmd.GiftID,
RuleVersion: config.RuleVersion,
ExperiencePool: decision.Stage,
SelectedTierID: candidate.TierID,
MultiplierPPM: candidate.MultiplierPPM,
BaseRewardCoins: candidate.BaseReward,
EffectiveRewardCoins: candidate.effectiveReward(),
RewardStatus: rewardStatus,
RTPWindowIndex: globalSnapshot.WindowIndex,
GiftRTPWindowIndex: giftSnapshot.WindowIndex,
GlobalBaseRTPPPM: globalRTP,
GiftBaseRTPPPM: giftRTP,
StageFeedback: decision.Trace.FinalReason == domain.StrategyReasonPaid && decision.Trace.OriginalTierID == "",
HighMultiplier: candidate.HighMultiplier,
CreatedAtMS: nowMS,
})
return nil
}
func (r *Repository) persistLuckyDynamicBatch(ctx context.Context, tx *sql.Tx, appCode string, cmd domain.DrawCommand, config domain.Config, state luckyDynamicBatchState, persistence luckyDynamicPersistence, nowMS int64) error {
if err := validateLuckyDynamicBatchState(state, cmd.CoinSpent); err != nil {
return err
}
globalWindows := append(append([]luckyRTPWindow(nil), state.GlobalWindows...), state.GlobalWindow)
giftWindows := append(append([]luckyRTPWindow(nil), state.GiftWindows...), state.GiftWindow)
for _, window := range append(globalWindows, giftWindows...) {
if _, err := tx.ExecContext(ctx, `
UPDATE lucky_rtp_windows
SET paid_draws = ?, wager_coins = ?, target_payout_coins = ?,
actual_base_payout_coins = ?, carry_ppm = ?, status = ?, updated_at_ms = ?
WHERE app_code = ? AND scope_type = ? AND scope_id = ? AND window_index = ?`,
window.PaidDraws, window.WagerCoins, window.TargetPayoutCoins, window.ActualPayoutCoins, window.CarryPPM, window.Status, nowMS,
appCode, window.ScopeType, window.ScopeID, window.WindowIndex,
); err != nil {
return err
}
}
if err := r.updateLuckyDynamicPoolNet(ctx, tx, appCode, luckyBasePoolScopeID(config), state.PublicIn, state.Payout, state.ProfitIn, state.AnchorIn, nowMS); err != nil {
return err
}
if _, err := tx.ExecContext(ctx, `
UPDATE lucky_user_states
SET paid_draws = ?, cumulative_wager_coins = ?, equivalent_draws = ?, loss_streak = ?,
pending_jackpot_tokens = ?, updated_at_ms = ?
WHERE app_code = ? AND user_id = ? AND gift_id = ?`,
state.UserState.PaidDraws, state.UserState.CumulativeWagerCoins, state.UserState.EquivalentDraws, state.UserState.LossStreak,
state.UserState.PendingJackpotTokens, nowMS, appCode, cmd.UserID, config.GiftID,
); err != nil {
return err
}
if err := r.addLuckyDynamicRiskPayout(ctx, tx, appCode, state.RiskCounters, state.Payout, nowMS); err != nil {
return err
}
if err := r.persistLuckyUserStrategyDay(ctx, tx, appCode, config.PoolID, cmd.UserID, state.DayState, nowMS); err != nil {
return err
}
if err := r.persistLuckyUserRTPHour(ctx, tx, appCode, config.PoolID, cmd.UserID, state.HourState, nowMS); err != nil {
return err
}
if err := r.insertLuckyUserRTPBoundaryEvents(ctx, tx, appCode, config.PoolID, cmd.UserID, state.Records, nowMS); err != nil {
return err
}
if persistence.external() {
return r.insertExternalLuckyGiftDrawItems(ctx, tx, appCode, persistence, state.Records, nowMS)
}
if err := r.insertLuckyDrawRecords(ctx, tx, state.Records); err != nil {
return err
}
if state.Payout <= 0 {
return nil
}
aggregate := luckyAggregateDrawResults(cmd, state.Results)
legacyState := luckyBatchDrawState{DrawIDs: state.DrawIDs, AggregateResult: aggregate, NeedsRewardSettlement: true}
return r.insertLuckyAggregateRewardSettlementOutbox(ctx, tx, appCode, cmd, legacyState, nowMS)
}
func luckyDynamicSelectedWeight(decision domain.StrategyDecision) int64 {
for _, item := range decision.Trace.Weights {
if item.TierID == decision.SelectedTier.ID {
return item.AdjustedWeightPPM
}
}
return decision.SelectedTier.JackpotWeight
}
func luckyDynamicLimitedReasons(trace domain.DecisionTrace) map[string]bool {
out := make(map[string]bool, len(trace.Removed)+1)
for _, removed := range trace.Removed {
out[removed.Reason] = true
}
if trace.BlockedReason != "" {
out[trace.BlockedReason] = true
}
return out
}
func validateLuckyDynamicBatchState(state luckyDynamicBatchState, expectedSpent int64) error {
if state.BasePool.Balance < 0 {
return fmt.Errorf("dynamic lucky gift pool became negative")
}
if state.PublicIn+state.ProfitIn+state.AnchorIn != expectedSpent {
return fmt.Errorf("dynamic lucky gift fund split does not conserve spend")
}
return nil
}
// insertExternalLuckyGiftDrawItems 给 external gift_count=N 保存 N 条独立决策;聚合响应仍是一行,
// 但 P/W 删除、token、六维风控和每抽池水位不能只藏在一个最终 multiplier 里。
func (r *Repository) insertExternalLuckyGiftDrawItems(ctx context.Context, tx *sql.Tx, appCode string, persistence luckyDynamicPersistence, records []luckyDrawRecordInput, nowMS int64) error {
const chunkSize = 200
for start := 0; start < len(records); start += chunkSize {
end := start + chunkSize
if end > len(records) {
end = len(records)
}
var query strings.Builder
query.WriteString(`
INSERT INTO external_lucky_gift_draw_items (
app_code, request_id, item_index, draw_id, command_id, external_user_id, pool_id,
rule_version, unit_amount, reward_amount, selected_tier_id, multiplier_ppm, stage,
candidate_snapshot_json, pool_snapshot_json, rtp_snapshot_json, reward_status,
paid_at_ms, created_at_ms
) VALUES `)
args := make([]any, 0, (end-start)*19)
for offset, record := range records[start:end] {
if offset > 0 {
query.WriteByte(',')
}
query.WriteString("(" + luckySQLPlaceholders(19) + ")")
candidateSnapshot, poolSnapshot, rtpSnapshot := luckyDrawRecordSnapshots(record)
args = append(args,
appCode, persistence.ExternalRequestID, start+offset+1, record.DrawID, record.Command.CommandID,
persistence.ExternalUserID, record.Config.PoolID, record.Config.RuleVersion, record.Command.CoinSpent,
record.Candidate.effectiveReward(), record.Candidate.TierID, record.Candidate.MultiplierPPM, record.ExperiencePool,
candidateSnapshot, poolSnapshot, rtpSnapshot, domain.StatusGranted,
luckyPaidTime(record.Command, nowMS).UnixMilli(), nowMS,
)
}
if _, err := tx.ExecContext(ctx, query.String(), args...); err != nil {
return err
}
}
return nil
}

View File

@ -0,0 +1,499 @@
package mysql
import (
"context"
"database/sql"
"fmt"
"sync"
"testing"
"time"
"hyapp/internal/testutil/mysqlschema"
"hyapp/pkg/appcode"
"hyapp/pkg/xerr"
domain "hyapp/services/lucky-gift-service/internal/domain/luckygift"
)
func TestDynamicLuckyGiftMySQLBatchAndExternalPaths(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_dynamic",
})
repo := &Repository{db: schema.DB}
ctx := appcode.WithContext(context.Background(), "lalu")
now := time.Date(2026, 7, 12, 12, 30, 0, 0, time.UTC).UnixMilli()
internalRule := dynamicMySQLTestRule("internal-dynamic")
internalRule.SettlementWindowWager = 99
// 参考价故意设为 10旧算法会把 99 流水错误折成 10 抽;本用例预置 10 抽/98 流水,证明 V3 不会因抽数已满而提前滚窗。
internalRule.GiftPriceReference = 10
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)
}
seedDynamicRTPWindowsBelowWagerThreshold(t, schema.DB, internalRule.PoolID, now-900)
command := domain.DrawCommand{
CommandID: "dynamic-99", PoolID: internalRule.PoolID, UserID: 1001, TargetUserID: 2001,
DeviceID: "device-1001", RoomID: "room-1", AnchorID: "anchor-1", GiftID: "super-lucky",
GiftCount: 99, CoinSpent: 100, GiftIncomeCoins: 1, PaidAtMS: now,
}
missingWalletPaidAt := command
missingWalletPaidAt.CommandID = "dynamic-missing-wallet-paid-at"
missingWalletPaidAt.PaidAtMS = 0
if _, err := repo.ExecuteLuckyGiftDraw(ctx, missingWalletPaidAt, now+123_456); !xerr.IsCode(err, xerr.InvalidArgument) {
t.Fatalf("dynamic_v3 missing wallet paid_at_ms should fail closed, got %v", err)
}
var missingPaidAtDraws int64
if err := schema.DB.QueryRow(`SELECT COUNT(*) FROM lucky_draw_records WHERE app_code='lalu' AND command_id=?`, missingWalletPaidAt.CommandID).Scan(&missingPaidAtDraws); err != nil || missingPaidAtDraws != 0 {
t.Fatalf("missing wallet paid_at_ms draw facts=%d err=%v", missingPaidAtDraws, err)
}
missingDevice := command
missingDevice.CommandID = "dynamic-missing-device"
missingDevice.DeviceID = ""
if _, err := repo.ExecuteLuckyGiftDraw(ctx, missingDevice, now); !xerr.IsCode(err, xerr.InvalidArgument) {
t.Fatalf("dynamic_v3 missing signed device_id should fail closed, got %v", err)
}
var missingDeviceDraws int64
if err := schema.DB.QueryRow(`SELECT COUNT(*) FROM lucky_draw_records WHERE app_code='lalu' AND command_id=?`, missingDevice.CommandID).Scan(&missingDeviceDraws); err != nil || missingDeviceDraws != 0 {
t.Fatalf("missing device_id draw facts=%d err=%v", missingDeviceDraws, err)
}
// 两个相同 command_id 的首次请求同时进入:两者都必须成功,后到者等待 command lock 后
// 从首次事务回读同一批 99 条事实,不能以唯一键冲突失败,更不能额外推进奖池/RTP/风控。
var internalResults [2]domain.DrawResult
var internalErrors [2]error
internalStart := make(chan struct{})
var internalWorkers sync.WaitGroup
for index := range internalResults {
internalWorkers.Add(1)
go func(index int) {
defer internalWorkers.Done()
<-internalStart
internalResults[index], internalErrors[index] = repo.ExecuteLuckyGiftDraw(ctx, command, now)
}(index)
}
close(internalStart)
internalWorkers.Wait()
for index, workerErr := range internalErrors {
if workerErr != nil {
t.Fatalf("internal concurrent worker %d: %v", index, workerErr)
}
}
first := internalResults[0]
if len(first.DrawIDs) != 99 {
t.Fatalf("aggregate draw ids=%d want=99", len(first.DrawIDs))
}
if internalResults[1].DrawID != first.DrawID ||
internalResults[1].EffectiveRewardCoins != first.EffectiveRewardCoins ||
internalResults[1].MultiplierPPM != first.MultiplierPPM ||
internalResults[1].RewardStatus != first.RewardStatus ||
fmt.Sprint(internalResults[1].DrawIDs) != fmt.Sprint(first.DrawIDs) ||
fmt.Sprint(internalResults[1].Hits) != fmt.Sprint(first.Hits) {
t.Fatalf("internal concurrent aggregate mismatch: %+v / %+v", first, internalResults[1])
}
var internalLockCount int64
if err := schema.DB.QueryRow(`
SELECT COUNT(*) FROM lucky_gift_command_locks
WHERE app_code='lalu' AND command_id=?`, command.CommandID,
).Scan(&internalLockCount); err != nil || internalLockCount != 1 {
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)
// 顺序重放必须只读回首次 99 条事实,不能再次入池、推进风控或生成钱包返奖。
replayed, err := repo.ExecuteLuckyGiftDraw(ctx, command, now+1)
if err != nil {
t.Fatalf("replay dynamic 99 draw: %v", err)
}
if replayed.DrawID != first.DrawID || replayed.EffectiveRewardCoins != first.EffectiveRewardCoins {
t.Fatalf("replay changed aggregate: first=%+v replay=%+v", first, replayed)
}
assertDynamicMySQLBatchFacts(t, schema.DB, "lalu", internalRule.PoolID, command.UserID, command.CommandID, 99, 100, 98, 1, 1)
assertDynamicTokenSurvivesUTCDayBoundary(t, repo, schema.DB, ctx, now)
externalRule := dynamicMySQLTestRule("external-dynamic")
if _, err := repo.PublishLuckyGiftRuleConfig(ctx, externalRule, now-500); err != nil {
t.Fatalf("publish external dynamic rule: %v", err)
}
missingExternalPaidAt := domain.ExternalDrawCommand{
AppCode: "lalu", ExternalUserID: "external-missing-paid-at", DeviceID: "trusted-device-missing-paid-at", RequestID: "external-dynamic-missing-paid-at",
GiftCount: 1, UnitAmount: 10, TotalAmount: 10, Currency: "COIN", PoolID: externalRule.PoolID,
}
if _, err := repo.ExecuteExternalGiftDraw(ctx, missingExternalPaidAt, now+234_567); !xerr.IsCode(err, xerr.InvalidArgument) {
t.Fatalf("external dynamic_v3 missing paid_at_ms should fail closed, got %v", err)
}
var missingExternalDraws int64
if err := schema.DB.QueryRow(`SELECT COUNT(*) FROM external_lucky_gift_draws WHERE app_code='lalu' AND request_id=?`, missingExternalPaidAt.RequestID).Scan(&missingExternalDraws); err != nil || missingExternalDraws != 0 {
t.Fatalf("missing external paid_at_ms draw facts=%d err=%v", missingExternalDraws, err)
}
external := domain.ExternalDrawCommand{
AppCode: "lalu", ExternalUserID: "external-42", DeviceID: "external-device-attested-42", RequestID: "external-dynamic-99",
GiftCount: 99, UnitAmount: 10, TotalAmount: 990, Currency: "COIN", PaidAtMS: now, PoolID: externalRule.PoolID,
}
missingExternalDevice := external
missingExternalDevice.RequestID = "external-dynamic-missing-device"
missingExternalDevice.DeviceID = ""
if _, err := repo.ExecuteExternalGiftDraw(ctx, missingExternalDevice, now); !xerr.IsCode(err, xerr.InvalidArgument) {
t.Fatalf("external dynamic_v3 missing caller-bound device_id should fail closed, got %v", err)
}
var missingExternalDeviceDraws int64
if err := schema.DB.QueryRow(`SELECT COUNT(*) FROM external_lucky_gift_draws WHERE app_code='lalu' AND request_id=?`, missingExternalDevice.RequestID).Scan(&missingExternalDeviceDraws); err != nil || missingExternalDeviceDraws != 0 {
t.Fatalf("missing external device draw facts=%d err=%v", missingExternalDeviceDraws, err)
}
var walletSettlementEventsBefore int64
if err := schema.DB.QueryRow(`
SELECT COUNT(*) FROM lucky_gift_outbox
WHERE app_code='lalu' AND event_type=?`, domain.EventTypeLuckyGiftRewardSettlement,
).Scan(&walletSettlementEventsBefore); err != nil {
t.Fatalf("query wallet settlement outbox before external: %v", err)
}
// 两个同 request_id 的首次请求同时进入时request lock 必须让后到者等待并读回同一结果。
var results [2]domain.ExternalDrawResult
var errorsByWorker [2]error
start := make(chan struct{})
var workers sync.WaitGroup
for index := range results {
workers.Add(1)
go func(index int) {
defer workers.Done()
<-start
results[index], errorsByWorker[index] = repo.ExecuteExternalGiftDraw(ctx, external, now)
}(index)
}
close(start)
workers.Wait()
for index, workerErr := range errorsByWorker {
if workerErr != nil {
t.Fatalf("external concurrent worker %d: %v", index, workerErr)
}
}
if results[0].DrawID == "" || results[0].DrawID != results[1].DrawID || results[0].RewardAmount != results[1].RewardAmount {
t.Fatalf("external concurrent idempotency mismatch: %+v / %+v", results[0], results[1])
}
var aggregateCount, itemCount, itemSpend, itemReward int64
if err := schema.DB.QueryRow(`SELECT COUNT(*) FROM external_lucky_gift_draws WHERE app_code='lalu' AND request_id=?`, external.RequestID).Scan(&aggregateCount); err != nil {
t.Fatalf("query external aggregate: %v", err)
}
if err := schema.DB.QueryRow(`
SELECT COUNT(*), COALESCE(SUM(unit_amount),0), COALESCE(SUM(reward_amount),0)
FROM external_lucky_gift_draw_items WHERE app_code='lalu' AND request_id=?`, external.RequestID,
).Scan(&itemCount, &itemSpend, &itemReward); err != nil {
t.Fatalf("query external items: %v", err)
}
if aggregateCount != 1 || itemCount != 99 || itemSpend != 990 || itemReward != results[0].RewardAmount {
t.Fatalf("external facts aggregate=%d items=%d spend=%d reward=%d result=%d", aggregateCount, itemCount, itemSpend, itemReward, results[0].RewardAmount)
}
expectedExternalDeviceScope := luckyPoolScopedID(externalRule.PoolID, externalLuckyDeviceID(external.AppCode, external.DeviceID))
legacyExternalUserScope := luckyPoolScopedID(externalRule.PoolID, externalLuckyDeviceID(external.AppCode, external.ExternalUserID))
var expectedDeviceCounters, legacyUserCounters int64
if err := schema.DB.QueryRow(`
SELECT COUNT(*) FROM lucky_risk_counters
WHERE app_code='lalu' AND scope_type='device' AND scope_id=?`, expectedExternalDeviceScope,
).Scan(&expectedDeviceCounters); err != nil {
t.Fatalf("query external device counter: %v", err)
}
if err := schema.DB.QueryRow(`
SELECT COUNT(*) FROM lucky_risk_counters
WHERE app_code='lalu' AND scope_type='device' AND scope_id=?`, legacyExternalUserScope,
).Scan(&legacyUserCounters); err != nil {
t.Fatalf("query legacy external-user device counter: %v", err)
}
if expectedDeviceCounters != 1 || legacyUserCounters != 0 {
t.Fatalf("external dynamic device scope counters trusted=%d legacy_user=%d", expectedDeviceCounters, legacyUserCounters)
}
if _, err := repo.ExecuteExternalGiftDraw(ctx, external, now+1); err != nil {
t.Fatalf("replay external dynamic result: %v", err)
}
var walletSettlementEventsAfter int64
if err := schema.DB.QueryRow(`
SELECT COUNT(*) FROM lucky_gift_outbox
WHERE app_code='lalu' AND event_type=?`, domain.EventTypeLuckyGiftRewardSettlement,
).Scan(&walletSettlementEventsAfter); err != nil {
t.Fatalf("query wallet settlement outbox after external: %v", err)
}
if walletSettlementEventsAfter != walletSettlementEventsBefore {
t.Fatalf("external dynamic draw created HyApp wallet settlements: before=%d after=%d", walletSettlementEventsBefore, walletSettlementEventsAfter)
}
}
func seedDynamicRTPWindowsBelowWagerThreshold(t *testing.T, db *sql.DB, poolID string, nowMS int64) {
t.Helper()
scopeID := poolID + ":v1"
for _, scopeType := range []string{"pool", "pool_gift"} {
if _, err := db.Exec(`
INSERT INTO lucky_rtp_windows (
app_code,scope_type,scope_id,window_index,target_rtp_ppm,control_window_draws,
paid_draws,wager_coins,target_payout_coins,actual_base_payout_coins,carry_ppm,status,created_at_ms,updated_at_ms
) VALUES ('lalu',?,?,1,980000,10,10,98,96,0,40000,'open',?,?)`,
scopeType, scopeID, nowMS, nowMS,
); err != nil {
t.Fatalf("seed %s RTP window: %v", scopeType, err)
}
}
}
func assertDynamicRTPWindowRollover(t *testing.T, db *sql.DB, poolID string) {
t.Helper()
scopeID := poolID + ":v1"
for _, scopeType := range []string{"pool", "pool_gift"} {
rows, err := db.Query(`
SELECT window_index, paid_draws, wager_coins, status FROM lucky_rtp_windows
WHERE app_code='lalu' AND scope_type=? AND scope_id=? ORDER BY window_index`, scopeType, scopeID)
if err != nil {
t.Fatalf("query %s rollover: %v", scopeType, err)
}
var indexes, paid, wagers []int64
var statuses []string
for rows.Next() {
var index, drawCount, wager int64
var status string
if err := rows.Scan(&index, &drawCount, &wager, &status); err != nil {
_ = rows.Close()
t.Fatalf("scan %s rollover: %v", scopeType, err)
}
indexes = append(indexes, index)
paid = append(paid, drawCount)
wagers = append(wagers, wager)
statuses = append(statuses, status)
}
if err := rows.Close(); err != nil {
t.Fatalf("close %s rollover rows: %v", scopeType, err)
}
if fmt.Sprint(indexes) != "[1 2]" || fmt.Sprint(paid) != "[11 98]" || fmt.Sprint(wagers) != "[100 98]" || fmt.Sprint(statuses) != "[closed open]" {
t.Fatalf("%s wager rollover indexes/paid/wager/status=%v/%v/%v/%v", scopeType, indexes, paid, wagers, statuses)
}
}
var firstWindow, secondWindow, firstGiftWindow, secondGiftWindow int64
if err := db.QueryRow(`
SELECT
COALESCE(SUM(CASE WHEN command_id LIKE 'dynamic-99#%' AND rtp_window_index=1 THEN 1 ELSE 0 END),0),
COALESCE(SUM(CASE WHEN command_id LIKE 'dynamic-99#%' AND rtp_window_index=2 THEN 1 ELSE 0 END),0),
COALESCE(SUM(CASE WHEN command_id LIKE 'dynamic-99#%' AND gift_rtp_window_index=1 THEN 1 ELSE 0 END),0),
COALESCE(SUM(CASE WHEN command_id LIKE 'dynamic-99#%' AND gift_rtp_window_index=2 THEN 1 ELSE 0 END),0)
FROM lucky_draw_records WHERE app_code='lalu' AND pool_id=?`, poolID,
).Scan(&firstWindow, &secondWindow, &firstGiftWindow, &secondGiftWindow); err != nil {
t.Fatalf("query draw rollover indexes: %v", err)
}
if firstWindow != 1 || secondWindow != 98 || firstGiftWindow != 1 || secondGiftWindow != 98 {
t.Fatalf("draw rollover global/gift=%d/%d %d/%d want=1/98 1/98", firstWindow, secondWindow, firstGiftWindow, secondGiftWindow)
}
}
func assertDynamicTokenSurvivesUTCDayBoundary(t *testing.T, repo *Repository, db *sql.DB, ctx context.Context, dayOneMS int64) {
t.Helper()
rule := dynamicMySQLTestRule("token-cross-day")
if _, err := repo.PublishLuckyGiftRuleConfig(ctx, rule, dayOneMS-100); err != nil {
t.Fatalf("publish token rule: %v", err)
}
userID := int64(3030)
earn := domain.DrawCommand{
CommandID: "token-earn", PoolID: rule.PoolID, UserID: userID, TargetUserID: 1,
DeviceID: "device-token", RoomID: "room-token", AnchorID: "anchor-token", GiftID: "super-lucky",
GiftCount: 1, CoinSpent: 5_000, GiftIncomeCoins: 50, PaidAtMS: dayOneMS,
}
if _, err := repo.ExecuteLuckyGiftDraw(ctx, earn, dayOneMS); err != nil {
t.Fatalf("earn milestone token: %v", err)
}
assertPendingTokenCount(t, db, rule.PoolID, userID, 1)
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
retain.CommandID = "token-retain-next-day"
retain.CoinSpent = 1
retain.GiftIncomeCoins = 0
retain.PaidAtMS = dayTwoMS
if _, err := repo.ExecuteLuckyGiftDraw(ctx, retain, dayTwoMS); err != nil {
t.Fatalf("retain token across UTC day: %v", err)
}
assertPendingTokenCount(t, db, rule.PoolID, userID, 1)
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"
consume.PaidAtMS++
result, err := repo.ExecuteLuckyGiftDraw(ctx, consume, dayTwoMS+1)
if err != nil {
t.Fatalf("consume retained token: %v", err)
}
if !result.HighMultiplier || result.EffectiveRewardCoins <= 0 {
t.Fatalf("retained token did not produce payable jackpot: %+v", result)
}
assertPendingTokenCount(t, db, rule.PoolID, userID, 0)
}
func assertPendingTokenCount(t *testing.T, db *sql.DB, poolID string, userID, want int64) {
t.Helper()
var got int64
if err := db.QueryRow(`
SELECT pending_jackpot_tokens FROM lucky_user_states
WHERE app_code='lalu' AND user_id=? AND gift_id=?`, userID, poolID,
).Scan(&got); err != nil {
t.Fatalf("query pending token: %v", err)
}
if got != want {
t.Fatalf("pending token=%d want=%d", got, want)
}
}
func assertExactRolling72HourBoundary(t *testing.T, repo *Repository, db *sql.DB, ctx context.Context, poolID string, endMS int64) {
t.Helper()
const hourMS = int64(time.Hour / time.Millisecond)
startMS := endMS - 72*hourMS
fullStartMS := startMS + (hourMS - startMS%hourMS)
fullEndMS := endMS - endMS%hourMS
userID := int64(9090)
if _, err := db.Exec(`
INSERT INTO lucky_user_rtp_hour_buckets (
app_code,pool_id,user_id,bucket_hour_ms,wager_coins,payout_coins,created_at_ms,updated_at_ms
) VALUES
('lalu',?,?,?,10,1,1,1),
('lalu',?,?,?,999,999,1,1)`,
poolID, userID, fullStartMS,
poolID, userID, fullEndMS,
); err != nil {
t.Fatalf("seed exact 72h buckets: %v", err)
}
events := []struct {
id string
atMS int64
wager int64
payout int64
}{
{"before-start", startMS - 1, 100, 100},
{"at-start", startMS, 2, 1},
{"lower-end", fullStartMS - 1, 3, 1},
{"inside-full-hour", fullStartMS, 100, 100},
{"upper-start", fullEndMS, 4, 1},
{"before-end", endMS - 1, 5, 1},
{"at-end", endMS, 100, 100},
}
for _, event := range events {
if _, err := db.Exec(`
INSERT INTO lucky_user_rtp_boundary_events (
app_code,pool_id,user_id,draw_id,occurred_at_ms,wager_coins,payout_coins,created_at_ms
) VALUES ('lalu',?,?,?,?,?,?,1)`,
poolID, userID, event.id, event.atMS, event.wager, event.payout,
); err != nil {
t.Fatalf("seed exact 72h event %s: %v", event.id, err)
}
}
tx, err := db.BeginTx(ctx, nil)
if err != nil {
t.Fatalf("begin exact 72h query: %v", err)
}
defer func() { _ = tx.Rollback() }()
rtp, err := repo.getLuckyUserRolling72HourRTP(ctx, tx, "lalu", poolID, userID, endMS)
if err != nil {
t.Fatalf("query exact 72h: %v", err)
}
// 完整小时只取 bucket=10/1边界严格包含 start、排除 end并且不重复计算 fullStart 事件。
if rtp != (domain.StrategyRTP{WagerCoins: 24, PayoutCoins: 5}) {
t.Fatalf("exact 72h RTP=%+v want wager=24 payout=5", rtp)
}
}
func assertDynamicMySQLBatchFacts(t *testing.T, db queryRower, appCode, poolID string, userID int64, commandPrefix string, draws, spent, public, profit, anchor int64) {
t.Helper()
var count, spentSum, publicSum, profitSum, anchorSum, invalidUnits int64
err := db.QueryRow(`
SELECT COUNT(*), COALESCE(SUM(coin_spent),0),
COALESCE(SUM(CAST(JSON_UNQUOTE(JSON_EXTRACT(pool_snapshot_json,'$.fund_split.public_pool_coins')) AS SIGNED)),0),
COALESCE(SUM(CAST(JSON_UNQUOTE(JSON_EXTRACT(pool_snapshot_json,'$.fund_split.profit_pool_coins')) AS SIGNED)),0),
COALESCE(SUM(CAST(JSON_UNQUOTE(JSON_EXTRACT(pool_snapshot_json,'$.fund_split.anchor_return_coins')) AS SIGNED)),0),
COALESCE(SUM(CASE WHEN coin_spent <>
CAST(JSON_UNQUOTE(JSON_EXTRACT(pool_snapshot_json,'$.fund_split.public_pool_coins')) AS SIGNED) +
CAST(JSON_UNQUOTE(JSON_EXTRACT(pool_snapshot_json,'$.fund_split.profit_pool_coins')) AS SIGNED) +
CAST(JSON_UNQUOTE(JSON_EXTRACT(pool_snapshot_json,'$.fund_split.anchor_return_coins')) AS SIGNED)
THEN 1 ELSE 0 END),0)
FROM lucky_draw_records
WHERE app_code=? AND pool_id=? AND command_id LIKE ?`, appCode, poolID, commandPrefix+"#%",
).Scan(&count, &spentSum, &publicSum, &profitSum, &anchorSum, &invalidUnits)
if err != nil {
t.Fatalf("query dynamic batch facts: %v", err)
}
if count != draws || spentSum != spent || publicSum != public || profitSum != profit || anchorSum != anchor || invalidUnits != 0 {
t.Fatalf("batch facts count/spent/split/invalid=%d/%d/%d/%d/%d/%d want=%d/%d/%d/%d/%d/0",
count, spentSum, publicSum, profitSum, anchorSum, invalidUnits, draws, spent, public, profit, anchor)
}
var eventCount, eventWager int64
if err := db.QueryRow(`
SELECT COUNT(*), COALESCE(SUM(wager_coins),0) FROM lucky_user_rtp_boundary_events
WHERE app_code=? AND pool_id=? AND user_id=?`, appCode, poolID, userID,
).Scan(&eventCount, &eventWager); err != nil {
t.Fatalf("query exact 72h events: %v", err)
}
if eventCount != draws || eventWager != spent {
t.Fatalf("72h events count/wager=%d/%d want=%d/%d", eventCount, eventWager, draws, spent)
}
}
type queryRower interface {
QueryRow(query string, args ...any) *sql.Row
}
func dynamicMySQLTestRule(poolID string) domain.RuleConfig {
stages := make([]domain.RuleStage, 0, 3)
for index, stageName := range []string{domain.StageNovice, domain.StageNormal, domain.StageAdvanced} {
stage := domain.RuleStage{Stage: stageName}
if index >= 1 {
stage.MinRecharge30DCoins = 1
}
if index >= 2 {
stage.MinRecharge7DCoins = 1
}
for _, tier := range []struct {
id string
multiplier int64
weight int64
}{{"0x", 0, 50_000}, {"0_5x", 500_000, 40_000}, {"1x", 1_000_000, 860_000}, {"2x", 2_000_000, 50_000}} {
stage.Tiers = append(stage.Tiers, domain.RuleTier{
Stage: stageName, TierID: fmt.Sprintf("%s_%s", stageName, tier.id),
MultiplierPPM: tier.multiplier, BaseWeightPPM: tier.weight, Enabled: true,
})
}
stages = append(stages, stage)
}
return 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: 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,
JackpotMultiplierPPMs: []int64{200_000_000, 500_000_000, 1_000_000_000},
JackpotGlobalRTPMaxPPM: 980_000, JackpotUserDayRTPMaxPPM: 960_000, JackpotUser72hRTPMaxPPM: 960_000,
JackpotSpendThresholdCoins: 5_000, MaxJackpotHitsPerUserDay: 5,
MaxSinglePayout: 1_000_000, UserHourlyPayoutCap: 2_000_000, UserDailyPayoutCap: 3_000_000,
DeviceDailyPayoutCap: 4_000_000, RoomHourlyPayoutCap: 5_000_000, AnchorDailyPayoutCap: 6_000_000,
Stages: stages,
}
}

View File

@ -0,0 +1,278 @@
package mysql
import (
"context"
"database/sql"
"fmt"
"strings"
"time"
domain "hyapp/services/lucky-gift-service/internal/domain/luckygift"
)
// luckyDynamicRiskCounter 是 dynamic_v3 在事务内锁定的单个风控窗口。
// scope_id 始终带 pool_id 前缀,防止同一用户在普通幸运与超级幸运池之间错误共享额度。
type luckyDynamicRiskCounter struct {
ScopeType string
ScopeID string
WindowType string
BucketKey string
Payout int64
}
type luckyUserStrategyDay struct {
StatDay string
WagerCoins int64
PayoutCoins int64
SpendCoins int64
JackpotHits int64
}
type luckyUserRTPHour struct {
BucketHourMS int64
WagerCoins int64
PayoutCoins int64
}
func luckyPaidTime(cmd domain.DrawCommand, nowMS int64) time.Time {
ms := cmd.PaidAtMS
if ms <= 0 {
ms = nowMS
}
return time.UnixMilli(ms).UTC()
}
func (r *Repository) getLuckyDynamicRiskCounters(ctx context.Context, tx *sql.Tx, appCode, poolID string, cmd domain.DrawCommand, nowMS int64) (map[string]luckyDynamicRiskCounter, error) {
paidAt := luckyPaidTime(cmd, nowMS)
hour := paidAt.Format("2006-01-02T15")
day := paidAt.Format("2006-01-02")
specs := []luckyDynamicRiskCounter{
{ScopeType: "user", ScopeID: luckyPoolScopedID(poolID, fmt.Sprintf("%d", cmd.UserID)), WindowType: "hour", BucketKey: hour},
{ScopeType: "user", ScopeID: luckyPoolScopedID(poolID, fmt.Sprintf("%d", cmd.UserID)), WindowType: "day", BucketKey: day},
{ScopeType: "device", ScopeID: luckyPoolScopedID(poolID, cmd.DeviceID), WindowType: "day", BucketKey: day},
{ScopeType: "room", ScopeID: luckyPoolScopedID(poolID, cmd.RoomID), WindowType: "hour", BucketKey: hour},
{ScopeType: "anchor", ScopeID: luckyPoolScopedID(poolID, cmd.AnchorID), WindowType: "day", BucketKey: day},
}
out := make(map[string]luckyDynamicRiskCounter, len(specs))
for _, spec := range specs {
counter, err := r.getOrCreateLuckyDynamicRiskCounter(ctx, tx, appCode, spec, nowMS)
if err != nil {
return nil, err
}
out[luckyDynamicRiskCounterKey(spec.ScopeType, spec.WindowType)] = counter
}
return out, nil
}
func (r *Repository) getOrCreateLuckyDynamicRiskCounter(ctx context.Context, tx *sql.Tx, appCode string, spec luckyDynamicRiskCounter, nowMS int64) (luckyDynamicRiskCounter, error) {
// 幂等建行先取得唯一键写锁,消除两个首次请求都读到 no rows 后其中一个 duplicate-key 失败的竞态。
if _, err := tx.ExecContext(ctx, `
INSERT INTO lucky_risk_counters (
app_code, scope_type, scope_id, window_type, bucket_key, payout_coins, created_at_ms, updated_at_ms
) VALUES (?, ?, ?, ?, ?, 0, ?, ?)
ON DUPLICATE KEY UPDATE updated_at_ms = updated_at_ms`,
appCode, spec.ScopeType, spec.ScopeID, spec.WindowType, spec.BucketKey, nowMS, nowMS,
); err != nil {
return luckyDynamicRiskCounter{}, err
}
err := tx.QueryRowContext(ctx, `
SELECT payout_coins
FROM lucky_risk_counters
WHERE app_code = ? AND scope_type = ? AND scope_id = ? AND window_type = ? AND bucket_key = ?
FOR UPDATE`,
appCode, spec.ScopeType, spec.ScopeID, spec.WindowType, spec.BucketKey,
).Scan(&spec.Payout)
return spec, err
}
func luckyDynamicRiskCapacity(config domain.Config, counters map[string]luckyDynamicRiskCounter) domain.StrategyRiskCapacity {
// 每个值都是“本抽还能支付多少”而不是配置总额0 是真实硬锁,不能被解释为 unlimited。
return domain.StrategyRiskCapacity{
Enabled: true,
SingleDrawCoins: config.MaxSinglePayout,
UserHourCoins: maxInt64(0, config.UserHourlyPayoutCap-counters[luckyDynamicRiskCounterKey("user", "hour")].Payout),
UserDayCoins: maxInt64(0, config.UserDailyPayoutCap-counters[luckyDynamicRiskCounterKey("user", "day")].Payout),
DeviceDayCoins: maxInt64(0, config.DeviceDailyPayoutCap-counters[luckyDynamicRiskCounterKey("device", "day")].Payout),
RoomHourCoins: maxInt64(0, config.RoomHourlyPayoutCap-counters[luckyDynamicRiskCounterKey("room", "hour")].Payout),
AnchorDayCoins: maxInt64(0, config.AnchorDailyPayoutCap-counters[luckyDynamicRiskCounterKey("anchor", "day")].Payout),
}
}
func (r *Repository) addLuckyDynamicRiskPayout(ctx context.Context, tx *sql.Tx, appCode string, counters map[string]luckyDynamicRiskCounter, payout, nowMS int64) error {
if payout <= 0 {
return nil
}
for _, counter := range counters {
if _, err := tx.ExecContext(ctx, `
UPDATE lucky_risk_counters
SET payout_coins = payout_coins + ?, updated_at_ms = ?
WHERE app_code = ? AND scope_type = ? AND scope_id = ? AND window_type = ? AND bucket_key = ?`,
payout, nowMS, appCode, counter.ScopeType, counter.ScopeID, counter.WindowType, counter.BucketKey,
); err != nil {
return err
}
}
return nil
}
func luckyDynamicRiskCounterKey(scopeType, windowType string) string {
return scopeType + ":" + windowType
}
func (r *Repository) getOrCreateLuckyUserStrategyDay(ctx context.Context, tx *sql.Tx, appCode, poolID string, userID int64, cmd domain.DrawCommand, nowMS int64) (luckyUserStrategyDay, error) {
day := luckyPaidTime(cmd, nowMS).Format("2006-01-02")
state := luckyUserStrategyDay{StatDay: day}
if _, err := tx.ExecContext(ctx, `
INSERT INTO lucky_user_strategy_days (
app_code, pool_id, user_id, stat_day, wager_coins, payout_coins, spend_coins,
jackpot_hits, created_at_ms, updated_at_ms
) VALUES (?, ?, ?, ?, 0, 0, 0, 0, ?, ?)
ON DUPLICATE KEY UPDATE updated_at_ms = updated_at_ms`,
appCode, poolID, userID, day, nowMS, nowMS,
); err != nil {
return luckyUserStrategyDay{}, err
}
err := tx.QueryRowContext(ctx, `
SELECT wager_coins, payout_coins, spend_coins, jackpot_hits
FROM lucky_user_strategy_days
WHERE app_code = ? AND pool_id = ? AND user_id = ? AND stat_day = ?
FOR UPDATE`, appCode, poolID, userID, day,
).Scan(&state.WagerCoins, &state.PayoutCoins, &state.SpendCoins, &state.JackpotHits)
return state, err
}
func (r *Repository) persistLuckyUserStrategyDay(ctx context.Context, tx *sql.Tx, appCode, poolID string, userID int64, state luckyUserStrategyDay, nowMS int64) error {
_, err := tx.ExecContext(ctx, `
UPDATE lucky_user_strategy_days
SET wager_coins = ?, payout_coins = ?, spend_coins = ?, jackpot_hits = ?, updated_at_ms = ?
WHERE app_code = ? AND pool_id = ? AND user_id = ? AND stat_day = ?`,
state.WagerCoins, state.PayoutCoins, state.SpendCoins, state.JackpotHits, nowMS,
appCode, poolID, userID, state.StatDay,
)
return err
}
func (r *Repository) getOrCreateLuckyUserRTPHour(ctx context.Context, tx *sql.Tx, appCode, poolID string, userID int64, cmd domain.DrawCommand, nowMS int64) (luckyUserRTPHour, error) {
paidAt := luckyPaidTime(cmd, nowMS)
bucketHourMS := paidAt.Truncate(time.Hour).UnixMilli()
state := luckyUserRTPHour{BucketHourMS: bucketHourMS}
if _, err := tx.ExecContext(ctx, `
INSERT INTO lucky_user_rtp_hour_buckets (
app_code, pool_id, user_id, bucket_hour_ms, wager_coins, payout_coins, created_at_ms, updated_at_ms
) VALUES (?, ?, ?, ?, 0, 0, ?, ?)
ON DUPLICATE KEY UPDATE updated_at_ms = updated_at_ms`,
appCode, poolID, userID, bucketHourMS, nowMS, nowMS,
); err != nil {
return luckyUserRTPHour{}, err
}
err := tx.QueryRowContext(ctx, `
SELECT wager_coins, payout_coins
FROM lucky_user_rtp_hour_buckets
WHERE app_code = ? AND pool_id = ? AND user_id = ? AND bucket_hour_ms = ?
FOR UPDATE`, appCode, poolID, userID, bucketHourMS,
).Scan(&state.WagerCoins, &state.PayoutCoins)
return state, err
}
func (r *Repository) getLuckyUserRolling72HourRTP(ctx context.Context, tx *sql.Tx, appCode, poolID string, userID int64, endMS int64) (domain.StrategyRTP, error) {
// 严格口径是 [end-72h,end),不能把“当前小时+前71整点桶”近似成 72h。
// 完整小时从聚合桶读取;头尾两个不足一小时的片段从专用边界事件读取,避免回扫 lucky_draw_records。
const hourMS = int64(time.Hour / time.Millisecond)
startMS := endMS - 72*hourMS
if startMS < 0 || endMS <= 0 {
return domain.StrategyRTP{}, fmt.Errorf("dynamic lucky gift 72h range is invalid")
}
fullStartMS := startMS
if remainder := startMS % hourMS; remainder != 0 {
fullStartMS += hourMS - remainder
}
fullEndMS := endMS - endMS%hourMS
var state domain.StrategyRTP
if fullStartMS < fullEndMS {
if err := tx.QueryRowContext(ctx, `
SELECT COALESCE(SUM(wager_coins), 0), COALESCE(SUM(payout_coins), 0)
FROM lucky_user_rtp_hour_buckets
WHERE app_code = ? AND pool_id = ? AND user_id = ?
AND bucket_hour_ms >= ? AND bucket_hour_ms < ?`,
appCode, poolID, userID, fullStartMS, fullEndMS,
).Scan(&state.WagerCoins, &state.PayoutCoins); err != nil {
return domain.StrategyRTP{}, err
}
}
for _, boundary := range [][2]int64{{startMS, fullStartMS}, {fullEndMS, endMS}} {
if boundary[0] >= boundary[1] {
continue
}
var wager, payout int64
if err := tx.QueryRowContext(ctx, `
SELECT COALESCE(SUM(wager_coins), 0), COALESCE(SUM(payout_coins), 0)
FROM lucky_user_rtp_boundary_events
WHERE app_code = ? AND pool_id = ? AND user_id = ?
AND occurred_at_ms >= ? AND occurred_at_ms < ?`,
appCode, poolID, userID, boundary[0], boundary[1],
).Scan(&wager, &payout); err != nil {
return domain.StrategyRTP{}, err
}
if state.WagerCoins > int64(^uint64(0)>>1)-wager || state.PayoutCoins > int64(^uint64(0)>>1)-payout {
return domain.StrategyRTP{}, fmt.Errorf("dynamic lucky gift 72h RTP overflow")
}
state.WagerCoins += wager
state.PayoutCoins += payout
}
return state, nil
}
func (r *Repository) insertLuckyUserRTPBoundaryEvents(ctx context.Context, tx *sql.Tx, appCode, poolID string, userID int64, records []luckyDrawRecordInput, nowMS int64) error {
const chunkSize = 200
for start := 0; start < len(records); start += chunkSize {
end := start + chunkSize
if end > len(records) {
end = len(records)
}
var query strings.Builder
query.WriteString(`
INSERT INTO lucky_user_rtp_boundary_events (
app_code, pool_id, user_id, draw_id, occurred_at_ms, wager_coins, payout_coins, created_at_ms
) VALUES `)
args := make([]any, 0, (end-start)*8)
for index, record := range records[start:end] {
if index > 0 {
query.WriteByte(',')
}
query.WriteString("(?,?,?,?,?,?,?,?)")
args = append(args,
appCode, poolID, userID, record.DrawID, luckyPaidTime(record.Command, nowMS).UnixMilli(),
record.Command.CoinSpent, record.Candidate.effectiveReward(), nowMS,
)
}
if _, err := tx.ExecContext(ctx, query.String(), args...); err != nil {
return err
}
}
return nil
}
func (r *Repository) persistLuckyUserRTPHour(ctx context.Context, tx *sql.Tx, appCode, poolID string, userID int64, state luckyUserRTPHour, nowMS int64) error {
_, err := tx.ExecContext(ctx, `
UPDATE lucky_user_rtp_hour_buckets
SET wager_coins = ?, payout_coins = ?, updated_at_ms = ?
WHERE app_code = ? AND pool_id = ? AND user_id = ? AND bucket_hour_ms = ?`,
state.WagerCoins, state.PayoutCoins, nowMS, appCode, poolID, userID, state.BucketHourMS,
)
return err
}
func (r *Repository) updateLuckyDynamicPoolNet(ctx context.Context, tx *sql.Tx, appCode, scopeID string, publicIn, payout, profitIn, anchorIncome, nowMS int64) error {
if publicIn < 0 || payout < 0 || profitIn < 0 || anchorIncome < 0 {
return fmt.Errorf("dynamic lucky gift pool delta cannot be negative")
}
_, err := tx.ExecContext(ctx, `
UPDATE lucky_pools
SET balance = balance + ? - ?, total_in = total_in + ?, total_out = total_out + ?,
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, luckyDynamicPoolScopeType, scopeID,
)
return err
}

View File

@ -0,0 +1,220 @@
package mysql
import (
crand "crypto/rand"
"fmt"
"math/big"
"sort"
domain "hyapp/services/lucky-gift-service/internal/domain/luckygift"
)
type luckyCryptoStrategyRandom struct{}
func (luckyCryptoStrategyRandom) Int63n(n int64) (int64, error) {
if n <= 0 {
return 0, fmt.Errorf("lucky strategy random bound must be positive")
}
value, err := crand.Int(crand.Reader, big.NewInt(n))
if err != nil {
return 0, err
}
return value.Int64(), nil
}
func luckyUsesDynamicStrategy(config domain.Config) bool {
return config.StrategyVersion == domain.StrategyDynamicV3
}
// luckyDynamicStrategyConfig 把不可变规则快照转换为纯策略输入。
// 同一倍率在三个充值阶段可能使用不同 tier_id运行内核按倍率合并并保存 stage->weight
// 从而既只有一个 0x 候选,又不会把某阶段不存在的倍率错误继承到另一阶段。
func luckyDynamicStrategyConfig(config domain.Config) domain.StrategyConfig {
stageNames := make([]string, 0, len(config.Stages))
rechargeStages := make([]domain.StrategyRechargeStage, 0, len(config.Stages))
for _, stage := range config.Stages {
stageNames = append(stageNames, stage.Stage)
rechargeStages = append(rechargeStages, domain.StrategyRechargeStage{
Name: stage.Stage,
MinRecharge7DCoins: stage.MinRecharge7DCoins,
MinRecharge30DCoins: stage.MinRecharge30DCoins,
})
}
jackpotSet := make(map[int64]struct{}, len(config.JackpotMultiplierPPMs))
for _, multiplier := range config.JackpotMultiplierPPMs {
jackpotSet[multiplier] = struct{}{}
}
tiersByMultiplier := make(map[int64]*domain.StrategyTier)
for _, stage := range config.Stages {
for _, configured := range stage.Tiers {
if !configured.Enabled {
continue
}
tier := tiersByMultiplier[configured.MultiplierPPM]
if tier == nil {
stageWeights := make(map[string]int64, len(stageNames))
for _, stageName := range stageNames {
stageWeights[stageName] = 0
}
tier = &domain.StrategyTier{
ID: fmt.Sprintf("multiplier_%d", configured.MultiplierPPM),
MultiplierPPM: configured.MultiplierPPM,
StageWeightPPM: stageWeights,
Enabled: true,
}
tiersByMultiplier[configured.MultiplierPPM] = tier
}
tier.StageWeightPPM[stage.Stage] += configured.BaseWeightPPM
}
}
// 大奖机制从显式倍率集合随机,不要求运营把 200x/500x/1000x 塞进普通概率表。
// 图片没有提供大奖之间的权重,因此统一使用 1这一选择会完整进入 decision trace不能被误认为基础概率。
for multiplier := range jackpotSet {
tier := tiersByMultiplier[multiplier]
if tier == nil {
stageWeights := make(map[string]int64, len(stageNames))
for _, stageName := range stageNames {
stageWeights[stageName] = 0
}
tier = &domain.StrategyTier{
ID: fmt.Sprintf("jackpot_%d", multiplier),
MultiplierPPM: multiplier,
StageWeightPPM: stageWeights,
Enabled: true,
}
tiersByMultiplier[multiplier] = tier
}
tier.Jackpot = true
tier.JackpotWeight = 1
}
multipliers := make([]int64, 0, len(tiersByMultiplier))
for multiplier := range tiersByMultiplier {
multipliers = append(multipliers, multiplier)
}
sort.Slice(multipliers, func(i, j int) bool { return multipliers[i] < multipliers[j] })
tiers := make([]domain.StrategyTier, 0, len(multipliers))
for _, multiplier := range multipliers {
tiers = append(tiers, *tiersByMultiplier[multiplier])
}
return domain.StrategyConfig{
Tiers: tiers,
PublicPoolRatePPM: config.PoolRatePPM,
ProfitPoolRatePPM: config.ProfitRatePPM,
AnchorReturnRatePPM: config.AnchorRatePPM,
ColdStartPoolCoins: config.InitialPoolCoins,
LowWaterThresholdCoins: config.LowWatermarkCoins,
HighWaterThresholdCoins: config.HighWatermarkCoins,
LowWaterFactorPPM: config.LowWaterNonzeroFactorPPM,
HighWaterFactorPPM: config.HighWaterNonzeroFactorPPM,
RechargeFactorPPM: config.RechargeBoostFactorPPM,
RechargeBoostStartMS: 0,
RechargeBoostEndMS: config.RechargeBoostWindowMS,
MissProtectionZeroDraws: config.LossStreakGuarantee,
DailyJackpotLimit: config.MaxJackpotHitsPerUserDay,
MilestoneSpendCoins: config.JackpotSpendThresholdCoins,
JackpotMechanism1Enabled: true,
JackpotMechanism2Enabled: config.JackpotSpendThresholdCoins > 0,
GlobalRTPMaxPPM: config.JackpotGlobalRTPMaxPPM,
UserDayRTPMaxPPM: config.JackpotUserDayRTPMaxPPM,
User72HourRTPMaxPPM: config.JackpotUser72hRTPMaxPPM,
RechargeStages: rechargeStages,
}
}
// luckyDynamicFundSplit 以不可变规则做唯一拆账,并校验 wallet 已经发给收礼人的 COIN 与规则主播份额完全一致。
// dynamic_v3 不能把旧 room 缺字段解释成 0也不能把 App 钱包配置的其他返币比例静默塞进盈利池;
// 启用新规则前必须先完成 wallet/room 契约滚动升级并让礼物返币配置与本规则一致。
func luckyDynamicFundSplit(totalSpent, actualAnchorIncome int64, config domain.StrategyConfig) (domain.StrategyFundSplit, error) {
configured, err := domain.SplitLuckyGiftStrategyFunds(totalSpent, config)
if err != nil {
return domain.StrategyFundSplit{}, err
}
if actualAnchorIncome != configured.AnchorReturnCoins {
return domain.StrategyFundSplit{}, fmt.Errorf(
"dynamic lucky gift anchor income mismatch: wallet=%d configured=%d",
actualAnchorIncome,
configured.AnchorReturnCoins,
)
}
return configured, nil
}
// luckyDynamicUnitFundSplits 联合分配整批的三个资金桶:每一抽都先满足
// public_i+profit_i+anchor_i=unit_spent_i同时整批三桶又精确等于总拆账。
// 不能分别对四个总量调用“余数落前几抽”,否则 100 金币/99 抽的首抽会出现 spent=2、三桶=3。
func luckyDynamicUnitFundSplits(totalSpent int64, drawCount int32, total domain.StrategyFundSplit) ([]domain.StrategyFundSplit, error) {
if totalSpent <= 0 || drawCount <= 0 || total.PublicPoolCoins < 0 || total.ProfitPoolCoins < 0 || total.AnchorReturnCoins < 0 ||
total.PublicPoolCoins+total.ProfitPoolCoins+total.AnchorReturnCoins != totalSpent {
return nil, fmt.Errorf("dynamic lucky gift batch split is invalid")
}
units := make([]int64, drawCount)
for index := int32(1); index <= drawCount; index++ {
units[index-1] = luckyDrawUnitSpend(totalSpent, drawCount, index)
if units[index-1] <= 0 {
return nil, fmt.Errorf("dynamic lucky gift unit spend must be positive")
}
}
publicParts, err := luckyDynamicApportionByCapacity(units, total.PublicPoolCoins)
if err != nil {
return nil, err
}
remaining := make([]int64, len(units))
for index := range units {
remaining[index] = units[index] - publicParts[index]
}
anchorParts, err := luckyDynamicApportionByCapacity(remaining, total.AnchorReturnCoins)
if err != nil {
return nil, err
}
parts := make([]domain.StrategyFundSplit, len(units))
var publicSum, profitSum, anchorSum int64
for index := range units {
parts[index] = domain.StrategyFundSplit{
PublicPoolCoins: publicParts[index],
AnchorReturnCoins: anchorParts[index],
ProfitPoolCoins: units[index] - publicParts[index] - anchorParts[index],
}
publicSum += parts[index].PublicPoolCoins
profitSum += parts[index].ProfitPoolCoins
anchorSum += parts[index].AnchorReturnCoins
}
if publicSum != total.PublicPoolCoins || profitSum != total.ProfitPoolCoins || anchorSum != total.AnchorReturnCoins {
return nil, fmt.Errorf("dynamic lucky gift unit split does not conserve bucket totals")
}
return parts, nil
}
// luckyDynamicApportionByCapacity 按每抽金额占比做累计配额,既避免前置抽被贪心塞满,
// 又保证每份不超过该抽剩余容量。big.Int 只用于防止 total*part 在大额外部请求上溢出 int64。
func luckyDynamicApportionByCapacity(capacities []int64, partTotal int64) ([]int64, error) {
var capacityTotal int64
for _, capacity := range capacities {
if capacity < 0 || capacityTotal > int64(^uint64(0)>>1)-capacity {
return nil, fmt.Errorf("dynamic lucky gift unit capacity is invalid")
}
capacityTotal += capacity
}
if partTotal < 0 || partTotal > capacityTotal || capacityTotal <= 0 {
return nil, fmt.Errorf("dynamic lucky gift apportioned total exceeds capacity")
}
parts := make([]int64, len(capacities))
denominator := big.NewInt(capacityTotal)
var prefix, allocated int64
for index, capacity := range capacities {
prefix += capacity
cumulative := new(big.Int).Mul(big.NewInt(prefix), big.NewInt(partTotal))
cumulative.Quo(cumulative, denominator)
if !cumulative.IsInt64() {
return nil, fmt.Errorf("dynamic lucky gift unit apportion overflow")
}
parts[index] = cumulative.Int64() - allocated
if parts[index] < 0 || parts[index] > capacity {
return nil, fmt.Errorf("dynamic lucky gift unit apportion exceeded one draw")
}
allocated += parts[index]
}
if allocated != partTotal {
return nil, fmt.Errorf("dynamic lucky gift unit apportion lost funds")
}
return parts, nil
}

View File

@ -0,0 +1,116 @@
package mysql
import (
"strings"
"testing"
domain "hyapp/services/lucky-gift-service/internal/domain/luckygift"
)
func TestDynamicLuckyGiftRequiresExplicitStableDeviceScope(t *testing.T) {
for _, value := range []string{"", " ", strings.Repeat("d", 129)} {
if _, err := normalizeLuckyDynamicDeviceID(value); err == nil {
t.Fatalf("dynamic_v3 accepted invalid device_id length=%d", len(value))
}
}
if got, err := normalizeLuckyDynamicDeviceID(" device-auth-session-1 "); err != nil || got != "device-auth-session-1" {
t.Fatalf("normalize explicit dynamic device scope got=%q err=%v", got, err)
}
}
func TestExternalDeviceScopeUsesCallerBoundDeviceInsteadOfExternalUser(t *testing.T) {
firstUser := domain.ExternalDrawCommand{AppCode: "aslan", ExternalUserID: "user-a", DeviceID: "shared-device"}
secondUser := domain.ExternalDrawCommand{AppCode: "aslan", ExternalUserID: "user-b", DeviceID: "shared-device"}
otherDevice := domain.ExternalDrawCommand{AppCode: "aslan", ExternalUserID: "user-a", DeviceID: "other-device"}
firstScope := externalLuckyDeviceID(firstUser.AppCode, firstUser.DeviceID)
if secondScope := externalLuckyDeviceID(secondUser.AppCode, secondUser.DeviceID); secondScope != firstScope {
// 同物理设备上的多账号必须共享设备日上限,不能继续按 external_user_id 分裂。
t.Fatalf("same external device produced different scopes: first=%q second=%q", firstScope, secondScope)
}
if otherScope := externalLuckyDeviceID(otherDevice.AppCode, otherDevice.DeviceID); otherScope == firstScope {
t.Fatalf("different external devices collapsed to one scope: %q", otherScope)
}
legacyA := externalLuckyFixedCompatibleDeviceID(domain.ExternalDrawCommand{AppCode: "aslan", ExternalUserID: "legacy-a"})
legacyB := externalLuckyFixedCompatibleDeviceID(domain.ExternalDrawCommand{AppCode: "aslan", ExternalUserID: "legacy-b"})
if legacyA == legacyB {
t.Fatalf("fixed_v2 legacy external users must retain their prior independent fallback scopes: %q", legacyA)
}
if historical := externalLuckyDeviceID("aslan", "legacy-a"); legacyA != historical {
t.Fatalf("fixed_v2 legacy device scope changed: got=%q historical=%q", legacyA, historical)
}
if fixedWithNewField := externalLuckyFixedCompatibleDeviceID(firstUser); fixedWithNewField != externalLuckyDeviceID("aslan", "user-a") {
t.Fatalf("fixed_v2 unexpectedly interpreted new device_id: %q", fixedWithNewField)
}
}
func TestLuckyDynamicUnitFundSplitsConserveEveryDrawAndBatchBucket(t *testing.T) {
tests := []struct {
name string
spent int64
drawCount int32
totalSplit domain.StrategyFundSplit
}{
{
name: "image 100 coins across 99 draws",
spent: 100,
drawCount: 99,
totalSplit: domain.StrategyFundSplit{
PublicPoolCoins: 98, ProfitPoolCoins: 1, AnchorReturnCoins: 1,
},
},
{
name: "990 coins keeps configured floor rounding totals",
spent: 990,
drawCount: 99,
totalSplit: domain.StrategyFundSplit{
PublicPoolCoins: 970, ProfitPoolCoins: 11, AnchorReturnCoins: 9,
},
},
{
name: "uneven unit prices and configured buckets",
spent: 101,
drawCount: 6,
totalSplit: domain.StrategyFundSplit{
PublicPoolCoins: 90, ProfitPoolCoins: 5, AnchorReturnCoins: 6,
},
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
parts, err := luckyDynamicUnitFundSplits(test.spent, test.drawCount, test.totalSplit)
if err != nil {
t.Fatalf("split: %v", err)
}
if len(parts) != int(test.drawCount) {
t.Fatalf("parts=%d want=%d", len(parts), test.drawCount)
}
var publicTotal, profitTotal, anchorTotal int64
for index, part := range parts {
unitSpent := luckyDrawUnitSpend(test.spent, test.drawCount, int32(index+1))
if got := part.PublicPoolCoins + part.ProfitPoolCoins + part.AnchorReturnCoins; got != unitSpent {
t.Fatalf("draw %d buckets=%d want unit spend=%d: %+v", index+1, got, unitSpent, part)
}
publicTotal += part.PublicPoolCoins
profitTotal += part.ProfitPoolCoins
anchorTotal += part.AnchorReturnCoins
}
if publicTotal != test.totalSplit.PublicPoolCoins || profitTotal != test.totalSplit.ProfitPoolCoins || anchorTotal != test.totalSplit.AnchorReturnCoins {
t.Fatalf("batch totals=%d/%d/%d want=%+v", publicTotal, profitTotal, anchorTotal, test.totalSplit)
}
})
}
}
func TestLuckyDynamicFundSplitRejectsWalletAnchorMismatch(t *testing.T) {
config := domain.DefaultLuckyGiftStrategyConfig()
if split, err := luckyDynamicFundSplit(100, 1, config); err != nil || split != (domain.StrategyFundSplit{PublicPoolCoins: 98, ProfitPoolCoins: 1, AnchorReturnCoins: 1}) {
t.Fatalf("matching split=%+v err=%v", split, err)
}
if _, err := luckyDynamicFundSplit(100, 0, config); err == nil {
t.Fatal("missing wallet anchor snapshot was accepted")
}
if _, err := luckyDynamicFundSplit(100, 10, config); err == nil {
t.Fatal("wallet anchor ratio different from rule was accepted")
}
}

View File

@ -22,6 +22,9 @@ import (
const ( const (
luckyPPMScale int64 = 1_000_000 luckyPPMScale int64 = 1_000_000
luckyBasePoolScopeType = "pool" luckyBasePoolScopeType = "pool"
// dynamic_v3 与 fixed_v2 的资金算法和启动规则不同,必须使用独立主键空间;
// 任何兼容读取都不能把两种策略重新折叠到同一口 pool 账本。
luckyDynamicPoolScopeType = "pool_dynamic_v3"
) )
type luckyRTPWindow struct { type luckyRTPWindow struct {
@ -53,6 +56,8 @@ type luckyPool struct {
ReserveFloor int64 ReserveFloor int64
TotalIn int64 TotalIn int64
TotalOut int64 TotalOut int64
ManualCreditTotal int64
ManualDebitTotal int64
} }
func (p luckyPool) capacity() int64 { func (p luckyPool) capacity() int64 {
@ -68,6 +73,9 @@ type luckyUserState struct {
CumulativeWagerCoins int64 CumulativeWagerCoins int64
EquivalentDraws int64 EquivalentDraws int64
LossStreak int64 LossStreak int64
// PendingJackpotTokens 是按 UTC 日消费赚取、但允许跨日等待资金的未消费大奖资格。
// 它属于 pool/user 生命周期,不能和“当天消费/当天命中次数”一起在午夜静默清零。
PendingJackpotTokens int64
} }
type luckyCandidate struct { type luckyCandidate struct {
@ -99,6 +107,12 @@ type luckyDrawRecordInput struct {
GlobalWindow luckyRTPWindow GlobalWindow luckyRTPWindow
GiftWindow luckyRTPWindow GiftWindow luckyRTPWindow
BasePool luckyPool BasePool luckyPool
// StrategyDecision 只在 dynamic_v3 存在完整权重、动态因子、P/W 重抽和大奖门槛会随 draw fact 固化,
// 后台以后修改规则版本也不会让历史结果失去可解释性。
StrategyDecision *domain.StrategyDecision
FundSplit domain.StrategyFundSplit
UserDayRTP domain.StrategyRTP
User72HourRTP domain.StrategyRTP
RewardStatus string RewardStatus string
NowMS int64 NowMS int64
} }
@ -159,6 +173,7 @@ func (r *Repository) CheckLuckyGift(ctx context.Context, cmd domain.CheckCommand
GiftID: cmd.GiftID, GiftID: cmd.GiftID,
GiftPrice: config.GiftPrice, GiftPrice: config.GiftPrice,
RuleVersion: config.RuleVersion, RuleVersion: config.RuleVersion,
StrategyVersion: config.StrategyVersion,
TargetRTPPPM: config.TargetRTPPPM, TargetRTPPPM: config.TargetRTPPPM,
ExperiencePool: luckyExperiencePool(state.EquivalentDraws, config), ExperiencePool: luckyExperiencePool(state.EquivalentDraws, config),
}, nil }, nil
@ -214,6 +229,13 @@ func (r *Repository) ExecuteLuckyGiftDrawBatch(ctx context.Context, cmds []domai
} }
func (r *Repository) executeLuckyGiftDrawBatch(ctx context.Context, tx *sql.Tx, cmd domain.DrawCommand, nowMS int64) ([]domain.DrawResult, error) { func (r *Repository) executeLuckyGiftDrawBatch(ctx context.Context, tx *sql.Tx, cmd domain.DrawCommand, nowMS int64) ([]domain.DrawResult, error) {
appCode := appcode.FromContext(ctx)
// lucky_draw_records 的唯一键只能拒绝重复写,无法让两个“结果尚不存在”的首次请求都成功:
// 两个事务可能同时读到空结果,后到者最终只能撞唯一键回滚。原始 command_id 锁行把首次执行
// 串行化;等待者拿到锁后会重新走下方事实探测并完整回读首次结果,绝不重新随机或推进资金状态。
if err := r.lockLuckyGiftCommand(ctx, tx, appCode, cmd.CommandID, nowMS); err != nil {
return nil, err
}
// gift_count 是抽奖次数coin_spent 是本次送礼总扣费;批量路径会把总扣费拆成 N 份单抽金额。 // gift_count 是抽奖次数coin_spent 是本次送礼总扣费;批量路径会把总扣费拆成 N 份单抽金额。
// 单抽仍走原来的严谨路径,避免为了批量优化扩大普通送礼的行为面。 // 单抽仍走原来的严谨路径,避免为了批量优化扩大普通送礼的行为面。
drawCount := cmd.GiftCount drawCount := cmd.GiftCount
@ -227,7 +249,6 @@ func (r *Repository) executeLuckyGiftDrawBatch(ctx context.Context, tx *sql.Tx,
} }
return []domain.DrawResult{result}, nil return []domain.DrawResult{result}, nil
} }
appCode := appcode.FromContext(ctx)
if drawCount > 1 { if drawCount > 1 {
// 旧实现把整笔送礼作为一次抽奖并使用原始 command_id 落库;批量拆分上线后遇到这类重试必须直接返回旧事实,不能追加子抽奖造成重复返奖。 // 旧实现把整笔送礼作为一次抽奖并使用原始 command_id 落库;批量拆分上线后遇到这类重试必须直接返回旧事实,不能追加子抽奖造成重复返奖。
if existing, exists, err := r.getLuckyDrawByCommand(ctx, tx, appCode, cmd.CommandID, true); err != nil || exists { if existing, exists, err := r.getLuckyDrawByCommand(ctx, tx, appCode, cmd.CommandID, true); err != nil || exists {
@ -249,6 +270,24 @@ func (r *Repository) executeLuckyGiftDrawBatch(ctx context.Context, tx *sql.Tx,
return r.executeOptimizedLuckyGiftDrawBatch(ctx, tx, cmd, drawCount, nowMS) return r.executeOptimizedLuckyGiftDrawBatch(ctx, tx, cmd, drawCount, nowMS)
} }
// lockLuckyGiftCommand 为内部钱包已扣费命令创建稳定的事务互斥点。
// SELECT ... FOR UPDATE 不能锁住尚不存在的 draw record幂等插入锁行后InnoDB 会让同一
// app_code+command_id 的并发事务等待首事务提交或回滚,再由显式 FOR UPDATE 固定后续锁顺序。
func (r *Repository) lockLuckyGiftCommand(ctx context.Context, tx *sql.Tx, appCode, commandID string, nowMS int64) error {
if _, err := tx.ExecContext(ctx, `
INSERT INTO lucky_gift_command_locks (app_code, command_id, created_at_ms)
VALUES (?, ?, ?)
ON DUPLICATE KEY UPDATE created_at_ms = created_at_ms`, appCode, commandID, nowMS,
); err != nil {
return err
}
var lockedCommandID string
return tx.QueryRowContext(ctx, `
SELECT command_id FROM lucky_gift_command_locks
WHERE app_code = ? AND command_id = ? FOR UPDATE`, appCode, commandID,
).Scan(&lockedCommandID)
}
// collectLuckyBatchDrawsByCommand 只服务批量命令重试:完整读回已提交的子抽奖,再由外层聚合成响应。 // collectLuckyBatchDrawsByCommand 只服务批量命令重试:完整读回已提交的子抽奖,再由外层聚合成响应。
// 这里不重新计算随机结果,避免同一个扣费命令因重试得到不同中奖结果。 // 这里不重新计算随机结果,避免同一个扣费命令因重试得到不同中奖结果。
func (r *Repository) collectLuckyBatchDrawsByCommand(ctx context.Context, tx *sql.Tx, appCode string, cmd domain.DrawCommand, drawCount int32) ([]domain.DrawResult, error) { func (r *Repository) collectLuckyBatchDrawsByCommand(ctx context.Context, tx *sql.Tx, appCode string, cmd domain.DrawCommand, drawCount int32) ([]domain.DrawResult, error) {
@ -283,6 +322,16 @@ func (r *Repository) executeOptimizedLuckyGiftDrawBatch(ctx context.Context, tx
if !exists || !ruleConfig.Enabled { if !exists || !ruleConfig.Enabled {
return nil, xerr.New(xerr.RuleNotActive, "lucky gift rule is not active") return nil, xerr.New(xerr.RuleNotActive, "lucky gift rule is not active")
} }
if ruleConfig.StrategyVersion == domain.StrategyDynamicV3 {
// dynamic_v3 必须在同一个事务里推进 P/W 重抽、充值层、大奖资格和六维风控;
// 不能先落入 fixed_v2 再在随机后修结果,否则审计和资金状态都会分叉。
return r.executeDynamicLuckyGiftDrawBatch(ctx, tx, cmd, drawCount, ruleConfig, luckyDynamicPersistence{}, nowMS)
}
if cmd.PaidAtMS <= 0 {
// fixed_v2 在旧 room/wallet 滚动发布期间允许使用 owner 收到请求的 UTC 时间兼容;
// dynamic_v3 在上面的专用路径严格拒绝缺失 wallet paid_at不能走这条降级。
cmd.PaidAtMS = nowMS
}
baseConfig, err := luckyRuntimeConfigFromRuleConfig(ruleConfig) baseConfig, err := luckyRuntimeConfigFromRuleConfig(ruleConfig)
if err != nil { if err != nil {
return nil, err return nil, err
@ -969,22 +1018,32 @@ func luckyDrawRecordArgs(input luckyDrawRecordInput) []any {
// luckyDrawRecordSnapshots 保存的是抽中当刻的可解释状态,不要求成为完整候选空间。 // luckyDrawRecordSnapshots 保存的是抽中当刻的可解释状态,不要求成为完整候选空间。
// 这里重点记录命中奖档、被过滤原因、结算窗口和当时池水位,足够定位“为什么本抽是 0/1/高倍”。 // 这里重点记录命中奖档、被过滤原因、结算窗口和当时池水位,足够定位“为什么本抽是 0/1/高倍”。
func luckyDrawRecordSnapshots(input luckyDrawRecordInput) (string, string, string) { func luckyDrawRecordSnapshots(input luckyDrawRecordInput) (string, string, string) {
candidateSnapshot, _ := json.Marshal(map[string]any{ candidateData := map[string]any{
"base_reward": input.Candidate.BaseReward, "base_reward": input.Candidate.BaseReward,
"limited": input.Limited, "limited": input.Limited,
"multiplier_ppm": input.Candidate.MultiplierPPM, "multiplier_ppm": input.Candidate.MultiplierPPM,
"weight": input.Candidate.Weight, "weight": input.Candidate.Weight,
}) }
poolSnapshot, _ := json.Marshal(map[string]any{ poolData := map[string]any{
"base_balance": input.BasePool.Balance, "base_balance": input.BasePool.Balance,
"base_reserve_floor": input.BasePool.ReserveFloor, "base_reserve_floor": input.BasePool.ReserveFloor,
"base_total_in": input.BasePool.TotalIn, "base_total_in": input.BasePool.TotalIn,
"base_total_out": input.BasePool.TotalOut, "base_total_out": input.BasePool.TotalOut,
}) }
rtpSnapshot, _ := json.Marshal(map[string]any{ rtpData := map[string]any{
"global_window": input.GlobalWindow.WindowIndex, "global_window": input.GlobalWindow.WindowIndex,
"gift_window": input.GiftWindow.WindowIndex, "gift_window": input.GiftWindow.WindowIndex,
}) }
if input.StrategyDecision != nil {
candidateData["dynamic_v3"] = input.StrategyDecision.Trace
poolData["fund_split"] = input.FundSplit
rtpData["user_day"] = input.UserDayRTP
rtpData["user_72h"] = input.User72HourRTP
rtpData["global"] = input.StrategyDecision.NextState.GlobalRTP
}
candidateSnapshot, _ := json.Marshal(candidateData)
poolSnapshot, _ := json.Marshal(poolData)
rtpSnapshot, _ := json.Marshal(rtpData)
return string(candidateSnapshot), string(poolSnapshot), string(rtpSnapshot) return string(candidateSnapshot), string(poolSnapshot), string(rtpSnapshot)
} }
@ -1043,6 +1102,20 @@ func (r *Repository) executeSingleLuckyGiftDraw(ctx context.Context, tx *sql.Tx,
if !exists || !ruleConfig.Enabled { if !exists || !ruleConfig.Enabled {
return domain.DrawResult{}, xerr.New(xerr.RuleNotActive, "lucky gift rule is not active") return domain.DrawResult{}, xerr.New(xerr.RuleNotActive, "lucky gift rule is not active")
} }
if ruleConfig.StrategyVersion == domain.StrategyDynamicV3 {
results, err := r.executeDynamicLuckyGiftDrawBatch(ctx, tx, cmd, 1, ruleConfig, luckyDynamicPersistence{}, nowMS)
if err != nil {
return domain.DrawResult{}, err
}
if len(results) != 1 {
return domain.DrawResult{}, xerr.New(xerr.Internal, "dynamic lucky gift single draw returned an invalid result count")
}
return results[0], nil
}
if cmd.PaidAtMS <= 0 {
// 仅历史 fixed_v2 接受缺失 paid_at 的滚动发布兼容;新 dynamic_v3 已在专用路径 fail-close。
cmd.PaidAtMS = nowMS
}
config, err := luckyRuntimeConfigFromRuleConfig(ruleConfig) config, err := luckyRuntimeConfigFromRuleConfig(ruleConfig)
if err != nil { if err != nil {
return domain.DrawResult{}, err return domain.DrawResult{}, err
@ -1870,15 +1943,29 @@ func (r *Repository) ListLuckyGiftPoolBalances(ctx context.Context, query domain
if r == nil || r.db == nil { if r == nil || r.db == nil {
return nil, xerr.New(xerr.Unavailable, "mysql repository is not configured") 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) poolID := strings.TrimSpace(query.PoolID)
if poolID != "" { if poolID != "" {
poolID = luckyPoolID(poolID) poolID = luckyPoolID(poolID)
} }
strategyVersion := strings.ToLower(strings.TrimSpace(query.StrategyVersion))
// 先读真实单池账本。这个查询不会 FOR UPDATE也不会调用 getOrCreate数据汇总刷新不能制造奖池行或改变余额。 // 先读两套真实账本。这个查询不会 FOR UPDATE也不会调用 getOrCreate后台列表不能制造奖池行或改变余额。
where := []string{"scope_type = ?"} // strategy_version 由内部 scope_type 显式映射;固定与动态绝不能再按 app+pool 折叠覆盖。
args := []any{luckyBasePoolScopeType} 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 != "" { if appCode != "" {
where = append(where, "app_code = ?") where = append(where, "app_code = ?")
args = append(args, appCode) args = append(args, appCode)
@ -1888,10 +1975,11 @@ func (r *Repository) ListLuckyGiftPoolBalances(ctx context.Context, query domain
args = append(args, poolID) args = append(args, poolID)
} }
rows, err := r.db.QueryContext(ctx, ` 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 FROM lucky_pools
WHERE `+strings.Join(where, " AND ")+` 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 { if err != nil {
return nil, err return nil, err
} }
@ -1900,12 +1988,21 @@ func (r *Repository) ListLuckyGiftPoolBalances(ctx context.Context, query domain
byKey := make(map[string]domain.PoolBalance) byKey := make(map[string]domain.PoolBalance)
for rows.Next() { for rows.Next() {
var item domain.PoolBalance 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 return nil, err
} }
mappedStrategy, ok := luckyStrategyForPoolScope(scopeType)
if !ok {
continue
}
item.StrategyVersion = mappedStrategy
item.Materialized = true item.Materialized = true
item.AvailableBalance = luckyAvailableBalance(item.Balance, item.ReserveFloor) 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 { if err := rows.Err(); err != nil {
return nil, err return nil, err
@ -1920,7 +2017,14 @@ func (r *Repository) ListLuckyGiftPoolBalances(ctx context.Context, query domain
if poolID != "" && luckyPoolID(config.PoolID) != poolID { if poolID != "" && luckyPoolID(config.PoolID) != poolID {
continue 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 { if _, exists := byKey[key]; exists {
continue continue
} }
@ -1928,12 +2032,20 @@ func (r *Repository) ListLuckyGiftPoolBalances(ctx context.Context, query domain
if err != nil { if err != nil {
return nil, err 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{ byKey[key] = domain.PoolBalance{
AppCode: appcode.Normalize(config.AppCode), AppCode: appcode.Normalize(config.AppCode),
PoolID: runtimeConfig.PoolID, PoolID: runtimeConfig.PoolID,
Balance: runtimeConfig.InitialBasePool, StrategyVersion: configStrategy,
ReserveFloor: runtimeConfig.BasePoolReserve, Balance: initialBalance,
AvailableBalance: luckyAvailableBalance(runtimeConfig.InitialBasePool, runtimeConfig.BasePoolReserve), ReserveFloor: reserveFloor,
AvailableBalance: luckyAvailableBalance(initialBalance, reserveFloor),
Materialized: false, Materialized: false,
UpdatedAtMS: config.CreatedAtMS, UpdatedAtMS: config.CreatedAtMS,
} }
@ -1941,14 +2053,22 @@ func (r *Repository) ListLuckyGiftPoolBalances(ctx context.Context, query domain
// 没有任何配置的新应用仍然需要在 ops-center 看到 default 池。这里用当前默认规则推导初始水位,但不写库。 // 没有任何配置的新应用仍然需要在 ops-center 看到 default 池。这里用当前默认规则推导初始水位,但不写库。
if appCode != "" && (poolID == "" || poolID == domain.DefaultPoolID) { 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 { if _, exists := byKey[key]; !exists {
var defaultBalance, defaultReserve int64
if defaultStrategy == domain.StrategyFixedV2 {
const defaultReferencePrice = int64(100) const defaultReferencePrice = int64(100)
defaultBalance := defaultReferencePrice * (950 + 500 + 2_375) defaultBalance = defaultReferencePrice * (950 + 500 + 2_375)
defaultReserve := defaultReferencePrice * (100 + 10 + 100) defaultReserve = defaultReferencePrice * (100 + 10 + 100)
}
byKey[key] = domain.PoolBalance{ byKey[key] = domain.PoolBalance{
AppCode: appCode, AppCode: appCode,
PoolID: domain.DefaultPoolID, PoolID: domain.DefaultPoolID,
StrategyVersion: defaultStrategy,
Balance: defaultBalance, Balance: defaultBalance,
ReserveFloor: defaultReserve, ReserveFloor: defaultReserve,
AvailableBalance: luckyAvailableBalance(defaultBalance, defaultReserve), AvailableBalance: luckyAvailableBalance(defaultBalance, defaultReserve),
@ -1965,7 +2085,10 @@ func (r *Repository) ListLuckyGiftPoolBalances(ctx context.Context, query domain
if items[i].AppCode != items[j].AppCode { if items[i].AppCode != items[j].AppCode {
return 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].PoolID < items[j].PoolID
}
return items[i].StrategyVersion < items[j].StrategyVersion
}) })
return items, nil return items, nil
} }
@ -2009,8 +2132,8 @@ func luckyDrawSummaryCanUseStats(query domain.DrawQuery) bool {
return query.UserID <= 0 && strings.TrimSpace(query.RoomID) == "" && strings.TrimSpace(query.Status) == "" return query.UserID <= 0 && strings.TrimSpace(query.RoomID) == "" && strings.TrimSpace(query.Status) == ""
} }
func luckyPoolBalanceKey(appCode string, poolID string) string { func luckyPoolBalanceKey(appCode string, poolID string, strategyVersion string) string {
return appcode.Normalize(appCode) + "\x00" + luckyPoolID(poolID) return appcode.Normalize(appCode) + "\x00" + luckyPoolID(poolID) + "\x00" + strings.ToLower(strings.TrimSpace(strategyVersion))
} }
func luckyAvailableBalance(balance int64, reserveFloor int64) int64 { func luckyAvailableBalance(balance int64, reserveFloor int64) int64 {
@ -2166,7 +2289,7 @@ func luckyRuntimeConfigFromRuleConfig(ruleConfig domain.RuleConfig) (domain.Conf
if referencePrice <= 0 { if referencePrice <= 0 {
return domain.Config{}, xerr.New(xerr.InvalidArgument, "lucky gift reference price must be positive") return domain.Config{}, xerr.New(xerr.InvalidArgument, "lucky gift reference price must be positive")
} }
// v2 用金币流水定义结算窗口,旧运行窗口仍以抽数推进;这里按参考价格折算成“等价抽数”,实际每抽会在 withCurrentWager 中继续按真实 coin_spent 累计 // fixed_v2 历史窗口仍按参考价格折算成“等价抽数”dynamic_v3 另行读取 SettlementWindowWager按真实 coin_spent 滚动
windowDraws := luckyCeilDiv(ruleConfig.SettlementWindowWager, referencePrice) windowDraws := luckyCeilDiv(ruleConfig.SettlementWindowWager, referencePrice)
if windowDraws <= 0 { if windowDraws <= 0 {
windowDraws = 1 windowDraws = 1
@ -2183,10 +2306,14 @@ func luckyRuntimeConfigFromRuleConfig(ruleConfig domain.RuleConfig) (domain.Conf
PoolID: poolID, PoolID: poolID,
Enabled: ruleConfig.Enabled, Enabled: ruleConfig.Enabled,
RuleVersion: ruleConfig.RuleVersion, RuleVersion: ruleConfig.RuleVersion,
StrategyVersion: ruleConfig.StrategyVersion,
GiftPrice: referencePrice, GiftPrice: referencePrice,
TargetRTPPPM: ruleConfig.TargetRTPPPM, TargetRTPPPM: ruleConfig.TargetRTPPPM,
PoolRatePPM: ruleConfig.PoolRatePPM, PoolRatePPM: ruleConfig.PoolRatePPM,
ProfitRatePPM: ruleConfig.ProfitRatePPM,
AnchorRatePPM: ruleConfig.AnchorRatePPM,
ControlBandPPM: ruleConfig.ControlBandPPM, ControlBandPPM: ruleConfig.ControlBandPPM,
SettlementWindowWager: ruleConfig.SettlementWindowWager,
GlobalWindowDraws: windowDraws, GlobalWindowDraws: windowDraws,
GiftWindowDraws: windowDraws, GiftWindowDraws: windowDraws,
NoviceMaxEquivalentDraws: ruleConfig.NoviceMaxEquivalentDraws, NoviceMaxEquivalentDraws: ruleConfig.NoviceMaxEquivalentDraws,
@ -2195,11 +2322,41 @@ func luckyRuntimeConfigFromRuleConfig(ruleConfig domain.RuleConfig) (domain.Conf
HighWaterPoolMultiple: 2, HighWaterPoolMultiple: 2,
InitialBasePool: referencePrice * (950 + 500 + 2_375), InitialBasePool: referencePrice * (950 + 500 + 2_375),
BasePoolReserve: referencePrice * (100 + 10 + 100), BasePoolReserve: referencePrice * (100 + 10 + 100),
InitialPoolCoins: ruleConfig.InitialPoolCoins,
LossStreakGuarantee: ruleConfig.LossStreakGuarantee,
LowWatermarkCoins: ruleConfig.LowWatermarkCoins,
LowWaterNonzeroFactorPPM: ruleConfig.LowWaterNonzeroFactorPPM,
HighWatermarkCoins: ruleConfig.HighWatermarkCoins,
HighWaterNonzeroFactorPPM: ruleConfig.HighWaterNonzeroFactorPPM,
RechargeBoostWindowMS: ruleConfig.RechargeBoostWindowMS,
RechargeBoostFactorPPM: ruleConfig.RechargeBoostFactorPPM,
JackpotMultiplierPPMs: append([]int64(nil), ruleConfig.JackpotMultiplierPPMs...),
JackpotGlobalRTPMaxPPM: ruleConfig.JackpotGlobalRTPMaxPPM,
JackpotUserDayRTPMaxPPM: ruleConfig.JackpotUserDayRTPMaxPPM,
JackpotUser72hRTPMaxPPM: ruleConfig.JackpotUser72hRTPMaxPPM,
JackpotSpendThresholdCoins: ruleConfig.JackpotSpendThresholdCoins,
MaxJackpotHitsPerUserDay: ruleConfig.MaxJackpotHitsPerUserDay,
MaxSinglePayout: ruleConfig.MaxSinglePayout,
UserHourlyPayoutCap: ruleConfig.UserHourlyPayoutCap,
UserDailyPayoutCap: ruleConfig.UserDailyPayoutCap,
DeviceDailyPayoutCap: ruleConfig.DeviceDailyPayoutCap,
RoomHourlyPayoutCap: ruleConfig.RoomHourlyPayoutCap,
AnchorDailyPayoutCap: ruleConfig.AnchorDailyPayoutCap,
Tiers: tiers, Tiers: tiers,
Stages: ruleConfig.Stages,
UpdatedByAdminID: ruleConfig.CreatedByAdminID, UpdatedByAdminID: ruleConfig.CreatedByAdminID,
CreatedAtMS: ruleConfig.CreatedAtMS, CreatedAtMS: ruleConfig.CreatedAtMS,
UpdatedAtMS: ruleConfig.CreatedAtMS, UpdatedAtMS: ruleConfig.CreatedAtMS,
} }
if config.StrategyVersion == "" {
config.StrategyVersion = domain.StrategyFixedV2
}
if config.StrategyVersion == domain.StrategyDynamicV3 {
// V3 只能由人工 credit 形成启动资金;旧配置残留的 initial_pool_coins 既不能注入账本,也不能进入策略冷启动状态。
config.InitialPoolCoins = 0
config.InitialBasePool = 0
config.BasePoolReserve = 0
}
config.MultiplierPPMs = luckyMultiplierPPMsFromTiers(config.Tiers, config.GiftPrice) config.MultiplierPPMs = luckyMultiplierPPMsFromTiers(config.Tiers, config.GiftPrice)
return config, nil return config, nil
} }
@ -2256,11 +2413,11 @@ func luckyCeilDiv(value, divisor int64) int64 {
func (r *Repository) getLuckyUserState(ctx context.Context, appCode string, userID int64, giftID string) (luckyUserState, error) { func (r *Repository) getLuckyUserState(ctx context.Context, appCode string, userID int64, giftID string) (luckyUserState, error) {
var state luckyUserState var state luckyUserState
err := r.db.QueryRowContext(ctx, ` err := r.db.QueryRowContext(ctx, `
SELECT paid_draws, cumulative_wager_coins, equivalent_draws, loss_streak SELECT paid_draws, cumulative_wager_coins, equivalent_draws, loss_streak, pending_jackpot_tokens
FROM lucky_user_states FROM lucky_user_states
WHERE app_code = ? AND user_id = ? AND gift_id = ?`, WHERE app_code = ? AND user_id = ? AND gift_id = ?`,
appCode, userID, giftID, appCode, userID, giftID,
).Scan(&state.PaidDraws, &state.CumulativeWagerCoins, &state.EquivalentDraws, &state.LossStreak) ).Scan(&state.PaidDraws, &state.CumulativeWagerCoins, &state.EquivalentDraws, &state.LossStreak, &state.PendingJackpotTokens)
if errors.Is(err, sql.ErrNoRows) { if errors.Is(err, sql.ErrNoRows) {
return luckyUserState{}, nil return luckyUserState{}, nil
} }
@ -2268,29 +2425,27 @@ func (r *Repository) getLuckyUserState(ctx context.Context, appCode string, user
} }
func (r *Repository) getLuckyUserStateForUpdate(ctx context.Context, tx *sql.Tx, appCode string, userID int64, giftID string, nowMS int64) (luckyUserState, error) { func (r *Repository) getLuckyUserStateForUpdate(ctx context.Context, tx *sql.Tx, appCode string, userID int64, giftID string, nowMS int64) (luckyUserState, error) {
var state luckyUserState // 先用幂等 INSERT 建行再 SELECT FOR UPDATE两个首次抽奖并发时唯一键会让第二个事务等待
err := tx.QueryRowContext(ctx, ` // 而不是两边都先读到 no rows 后由其中一个以 duplicate key 失败。
SELECT paid_draws, cumulative_wager_coins, equivalent_draws, loss_streak
FROM lucky_user_states
WHERE app_code = ? AND user_id = ? AND gift_id = ?
FOR UPDATE`,
appCode, userID, giftID,
).Scan(&state.PaidDraws, &state.CumulativeWagerCoins, &state.EquivalentDraws, &state.LossStreak)
if err == nil {
return state, nil
}
if !errors.Is(err, sql.ErrNoRows) {
return luckyUserState{}, err
}
if _, err := tx.ExecContext(ctx, ` if _, err := tx.ExecContext(ctx, `
INSERT INTO lucky_user_states ( INSERT INTO lucky_user_states (
app_code, user_id, gift_id, paid_draws, cumulative_wager_coins, equivalent_draws, loss_streak, created_at_ms, updated_at_ms app_code, user_id, gift_id, paid_draws, cumulative_wager_coins, equivalent_draws, loss_streak,
) VALUES (?, ?, ?, 0, 0, 0, 0, ?, ?)`, pending_jackpot_tokens, created_at_ms, updated_at_ms
) VALUES (?, ?, ?, 0, 0, 0, 0, 0, ?, ?)
ON DUPLICATE KEY UPDATE updated_at_ms = updated_at_ms`,
appCode, userID, giftID, nowMS, nowMS, appCode, userID, giftID, nowMS, nowMS,
); err != nil { ); err != nil {
return luckyUserState{}, err return luckyUserState{}, err
} }
return luckyUserState{}, nil var state luckyUserState
err := tx.QueryRowContext(ctx, `
SELECT paid_draws, cumulative_wager_coins, equivalent_draws, loss_streak, pending_jackpot_tokens
FROM lucky_user_states
WHERE app_code = ? AND user_id = ? AND gift_id = ?
FOR UPDATE`,
appCode, userID, giftID,
).Scan(&state.PaidDraws, &state.CumulativeWagerCoins, &state.EquivalentDraws, &state.LossStreak, &state.PendingJackpotTokens)
return state, err
} }
func (r *Repository) getOpenLuckyRTPWindow(ctx context.Context, tx *sql.Tx, appCode, scopeType, scopeID string, windowDraws, targetPPM, nowMS int64) (luckyRTPWindow, error) { func (r *Repository) getOpenLuckyRTPWindow(ctx context.Context, tx *sql.Tx, appCode, scopeType, scopeID string, windowDraws, targetPPM, nowMS int64) (luckyRTPWindow, error) {
@ -2317,6 +2472,35 @@ func (r *Repository) getOpenLuckyRTPWindow(ctx context.Context, tx *sql.Tx, appC
return r.createLuckyRTPWindow(ctx, tx, appCode, scopeType, scopeID, window.WindowIndex+1, window.CarryPPM, windowDraws, targetPPM, nowMS) return r.createLuckyRTPWindow(ctx, tx, appCode, scopeType, scopeID, window.WindowIndex+1, window.CarryPPM, windowDraws, targetPPM, nowMS)
} }
// getOpenLuckyDynamicRTPWindow 只服务 dynamic_v3不可变 rule_version scope 已经固定真实流水阈值,
// 因此窗口是否结束只看 wager_coins。control_window_draws 继续保存 fixed_v2 的兼容观测值,不能参与 V3 滚动判断。
func (r *Repository) getOpenLuckyDynamicRTPWindow(ctx context.Context, tx *sql.Tx, appCode, scopeType, scopeID string, windowWager, legacyWindowDraws, targetPPM, nowMS int64) (luckyRTPWindow, error) {
if windowWager <= 0 {
return luckyRTPWindow{}, xerr.New(xerr.InvalidArgument, "dynamic lucky gift settlement window wager must be positive")
}
window, exists, err := r.getLatestLuckyRTPWindow(ctx, tx, appCode, scopeType, scopeID, true)
if err != nil {
return luckyRTPWindow{}, err
}
if !exists {
return r.createLuckyRTPWindow(ctx, tx, appCode, scopeType, scopeID, 1, 0, legacyWindowDraws, targetPPM, nowMS)
}
if window.Status == "open" && window.WagerCoins < windowWager {
return window, nil
}
if window.Status == "open" {
if _, err := tx.ExecContext(ctx, `
UPDATE lucky_rtp_windows
SET status = 'closed', updated_at_ms = ?
WHERE app_code = ? AND scope_type = ? AND scope_id = ? AND window_index = ?`,
nowMS, appCode, scopeType, scopeID, window.WindowIndex,
); err != nil {
return luckyRTPWindow{}, err
}
}
return r.createLuckyRTPWindow(ctx, tx, appCode, scopeType, scopeID, window.WindowIndex+1, window.CarryPPM, legacyWindowDraws, targetPPM, nowMS)
}
func (r *Repository) getLatestLuckyRTPWindow(ctx context.Context, tx *sql.Tx, appCode, scopeType, scopeID string, forUpdate bool) (luckyRTPWindow, bool, error) { func (r *Repository) getLatestLuckyRTPWindow(ctx context.Context, tx *sql.Tx, appCode, scopeType, scopeID string, forUpdate bool) (luckyRTPWindow, bool, error) {
lockSQL := "" lockSQL := ""
if forUpdate { if forUpdate {
@ -2343,51 +2527,53 @@ func (r *Repository) getLatestLuckyRTPWindow(ctx context.Context, tx *sql.Tx, ap
} }
func (r *Repository) createLuckyRTPWindow(ctx context.Context, tx *sql.Tx, appCode, scopeType, scopeID string, index, carry, windowDraws, targetPPM, nowMS int64) (luckyRTPWindow, error) { func (r *Repository) createLuckyRTPWindow(ctx context.Context, tx *sql.Tx, appCode, scopeType, scopeID string, index, carry, windowDraws, targetPPM, nowMS int64) (luckyRTPWindow, error) {
window := luckyRTPWindow{ // 同一 scope 的首次请求可能并发创建同一 window幂等 INSERT 先以唯一键串行化,
ScopeType: scopeType, // 随后必须重新读取实际行,不能在 duplicate 时返回一个全 0 的本地假快照覆盖已提交进度。
ScopeID: scopeID,
WindowIndex: index,
TargetRTPPPM: targetPPM,
ControlDraws: windowDraws,
CarryPPM: carry,
Status: "open",
}
if _, err := tx.ExecContext(ctx, ` if _, err := tx.ExecContext(ctx, `
INSERT INTO lucky_rtp_windows ( INSERT INTO lucky_rtp_windows (
app_code, scope_type, scope_id, window_index, target_rtp_ppm, control_window_draws, app_code, scope_type, scope_id, window_index, target_rtp_ppm, control_window_draws,
paid_draws, wager_coins, target_payout_coins, actual_base_payout_coins, carry_ppm, status, created_at_ms, updated_at_ms paid_draws, wager_coins, target_payout_coins, actual_base_payout_coins, carry_ppm, status, created_at_ms, updated_at_ms
) VALUES (?, ?, ?, ?, ?, ?, 0, 0, 0, 0, ?, 'open', ?, ?)`, ) VALUES (?, ?, ?, ?, ?, ?, 0, 0, 0, 0, ?, 'open', ?, ?)
appCode, scopeType, scopeID, index, targetPPM, windowDraws, window.CarryPPM, nowMS, nowMS, ON DUPLICATE KEY UPDATE updated_at_ms = updated_at_ms`,
appCode, scopeType, scopeID, index, targetPPM, windowDraws, carry, nowMS, nowMS,
); err != nil { ); err != nil {
return luckyRTPWindow{}, err return luckyRTPWindow{}, err
} }
return window, nil var window luckyRTPWindow
err := tx.QueryRowContext(ctx, `
SELECT scope_type, scope_id, window_index, target_rtp_ppm, control_window_draws,
paid_draws, wager_coins, target_payout_coins, actual_base_payout_coins, carry_ppm, status
FROM lucky_rtp_windows
WHERE app_code = ? AND scope_type = ? AND scope_id = ? AND window_index = ?
FOR UPDATE`, appCode, scopeType, scopeID, index,
).Scan(&window.ScopeType, &window.ScopeID, &window.WindowIndex, &window.TargetRTPPPM, &window.ControlDraws,
&window.PaidDraws, &window.WagerCoins, &window.TargetPayoutCoins, &window.ActualPayoutCoins, &window.CarryPPM, &window.Status)
return window, err
} }
func (r *Repository) getOrCreateLuckyPool(ctx context.Context, tx *sql.Tx, appCode, scopeType, scopeID string, initialBalance, reserveFloor, nowMS int64) (luckyPool, error) { func (r *Repository) getOrCreateLuckyPool(ctx context.Context, tx *sql.Tx, appCode, scopeType, scopeID string, initialBalance, reserveFloor, nowMS int64) (luckyPool, error) {
row := tx.QueryRowContext(ctx, ` // 初始注资只能由唯一键胜出的事务写一次;第二个首次请求等待后读取真实余额,不能重复 seed。
SELECT scope_type, scope_id, balance, reserve_floor, total_in, total_out if _, err := tx.ExecContext(ctx, `
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 (?, ?, ?, ?, ?, 0, 0, 0, 0, ?, ?, ?)
ON DUPLICATE KEY UPDATE updated_at_ms = updated_at_ms`,
appCode, scopeType, scopeID, initialBalance, reserveFloor, initialBalance, nowMS, nowMS,
); err != nil {
return luckyPool{}, err
}
var pool 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 FROM lucky_pools
WHERE app_code = ? AND scope_type = ? AND scope_id = ? WHERE app_code = ? AND scope_type = ? AND scope_id = ?
FOR UPDATE`, FOR UPDATE`,
appCode, scopeType, scopeID, appCode, scopeType, scopeID,
) ).Scan(&pool.ScopeType, &pool.ScopeID, &pool.Balance, &pool.ReserveFloor, &pool.TotalIn, &pool.TotalOut,
var pool luckyPool &pool.ManualCreditTotal, &pool.ManualDebitTotal)
if err := row.Scan(&pool.ScopeType, &pool.ScopeID, &pool.Balance, &pool.ReserveFloor, &pool.TotalIn, &pool.TotalOut); err == nil { return pool, err
return pool, nil
} else if !errors.Is(err, sql.ErrNoRows) {
return luckyPool{}, err
}
pool = luckyPool{ScopeType: scopeType, ScopeID: scopeID, Balance: initialBalance, ReserveFloor: reserveFloor}
if _, err := tx.ExecContext(ctx, `
INSERT INTO lucky_pools (
app_code, scope_type, scope_id, balance, reserve_floor, total_in, total_out, created_at_ms, updated_at_ms
) VALUES (?, ?, ?, ?, ?, 0, 0, ?, ?)`,
appCode, scopeType, scopeID, initialBalance, reserveFloor, nowMS, nowMS,
); err != nil {
return luckyPool{}, err
}
return pool, nil
} }
func (r *Repository) creditLuckyBasePool(ctx context.Context, tx *sql.Tx, appCode string, config domain.Config, spent int64, pool *luckyPool, nowMS int64) error { func (r *Repository) creditLuckyBasePool(ctx context.Context, tx *sql.Tx, appCode string, config domain.Config, spent int64, pool *luckyPool, nowMS int64) error {

View File

@ -3,7 +3,9 @@ package mysql
import ( import (
"context" "context"
"database/sql" "database/sql"
"encoding/json"
"errors" "errors"
"fmt"
"strings" "strings"
"hyapp/pkg/appcode" "hyapp/pkg/appcode"
@ -16,7 +18,7 @@ type luckyRuleQueryer interface {
QueryRowContext(context.Context, string, ...any) *sql.Row QueryRowContext(context.Context, string, ...any) *sql.Row
} }
// GetLuckyGiftRuleConfig 读取指定奖池最新 v2 规则版本;旧抽奖运行表不会参与后台配置读取。 // GetLuckyGiftRuleConfig 读取指定奖池最新不可变规则版本;旧抽奖运行表不会参与后台配置读取。
func (r *Repository) GetLuckyGiftRuleConfig(ctx context.Context, poolID string) (domain.RuleConfig, bool, error) { func (r *Repository) GetLuckyGiftRuleConfig(ctx context.Context, poolID string) (domain.RuleConfig, bool, error) {
if r == nil || r.db == nil { if r == nil || r.db == nil {
return domain.RuleConfig{}, false, xerr.New(xerr.Unavailable, "mysql repository is not configured") return domain.RuleConfig{}, false, xerr.New(xerr.Unavailable, "mysql repository is not configured")
@ -24,7 +26,7 @@ func (r *Repository) GetLuckyGiftRuleConfig(ctx context.Context, poolID string)
return r.getLuckyGiftRuleConfig(ctx, r.db, appcode.FromContext(ctx), luckyPoolID(poolID), false) return r.getLuckyGiftRuleConfig(ctx, r.db, appcode.FromContext(ctx), luckyPoolID(poolID), false)
} }
// PublishLuckyGiftRuleConfig 只新增不可变版本,不覆盖历史配置;后续 v2 抽奖引擎按版本读取快照。 // PublishLuckyGiftRuleConfig 只新增不可变版本,不覆盖历史配置;运行引擎按 strategy_version 和 rule_version 读取完整快照。
func (r *Repository) PublishLuckyGiftRuleConfig(ctx context.Context, config domain.RuleConfig, nowMS int64) (domain.RuleConfig, error) { func (r *Repository) PublishLuckyGiftRuleConfig(ctx context.Context, config domain.RuleConfig, nowMS int64) (domain.RuleConfig, error) {
if r == nil || r.db == nil { if r == nil || r.db == nil {
return domain.RuleConfig{}, xerr.New(xerr.Unavailable, "mysql repository is not configured") return domain.RuleConfig{}, xerr.New(xerr.Unavailable, "mysql repository is not configured")
@ -38,6 +40,22 @@ func (r *Repository) PublishLuckyGiftRuleConfig(ctx context.Context, config doma
appCode := appcode.FromContext(ctx) appCode := appcode.FromContext(ctx)
config.AppCode = appCode config.AppCode = appCode
config.PoolID = luckyPoolID(config.PoolID) config.PoolID = luckyPoolID(config.PoolID)
config.StrategyVersion = strings.ToLower(strings.TrimSpace(config.StrategyVersion))
if config.StrategyVersion == "" {
// 升级前的发布请求没有 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{}
}
jackpotMultiplierPPMsJSON, err := json.Marshal(config.JackpotMultiplierPPMs)
if err != nil {
return domain.RuleConfig{}, fmt.Errorf("marshal lucky gift jackpot multipliers: %w", err)
}
config.RuleVersion = 1 config.RuleVersion = 1
config.CreatedAtMS = nowMS config.CreatedAtMS = nowMS
if config.EffectiveFromMS <= 0 { if config.EffectiveFromMS <= 0 {
@ -61,15 +79,33 @@ func (r *Repository) PublishLuckyGiftRuleConfig(ctx context.Context, config doma
} }
if _, err := tx.ExecContext(ctx, ` if _, err := tx.ExecContext(ctx, `
INSERT INTO lucky_gift_rule_versions ( INSERT INTO lucky_gift_rule_versions (
app_code, pool_id, rule_version, enabled, target_rtp_ppm, pool_rate_ppm, app_code, pool_id, rule_version, enabled, strategy_version, target_rtp_ppm, pool_rate_ppm,
profit_rate_ppm, anchor_rate_ppm,
settlement_window_wager, control_band_ppm, gift_price_reference, settlement_window_wager, control_band_ppm, gift_price_reference,
novice_max_equivalent_draws, normal_max_equivalent_draws, novice_max_equivalent_draws, normal_max_equivalent_draws,
effective_from_ms, created_by_admin_id, created_at_ms effective_from_ms, created_by_admin_id, created_at_ms,
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)`, initial_pool_coins, loss_streak_guarantee,
config.AppCode, config.PoolID, config.RuleVersion, config.Enabled, config.TargetRTPPPM, config.PoolRatePPM, low_watermark_coins, low_water_nonzero_factor_ppm,
high_watermark_coins, high_water_nonzero_factor_ppm,
recharge_boost_window_ms, recharge_boost_factor_ppm, jackpot_multiplier_ppms,
jackpot_global_rtp_max_ppm, jackpot_user_day_rtp_max_ppm, jackpot_user_72h_rtp_max_ppm,
jackpot_spend_threshold_coins, max_jackpot_hits_per_user_day,
max_single_payout, user_hourly_payout_cap, user_daily_payout_cap,
device_daily_payout_cap, room_hourly_payout_cap, anchor_daily_payout_cap
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
config.AppCode, config.PoolID, config.RuleVersion, config.Enabled, config.StrategyVersion, config.TargetRTPPPM, config.PoolRatePPM,
config.ProfitRatePPM, config.AnchorRatePPM,
config.SettlementWindowWager, config.ControlBandPPM, config.GiftPriceReference, config.SettlementWindowWager, config.ControlBandPPM, config.GiftPriceReference,
config.NoviceMaxEquivalentDraws, config.NormalMaxEquivalentDraws, config.NoviceMaxEquivalentDraws, config.NormalMaxEquivalentDraws,
config.EffectiveFromMS, config.CreatedByAdminID, config.CreatedAtMS, config.EffectiveFromMS, config.CreatedByAdminID, config.CreatedAtMS,
config.InitialPoolCoins, config.LossStreakGuarantee,
config.LowWatermarkCoins, config.LowWaterNonzeroFactorPPM,
config.HighWatermarkCoins, config.HighWaterNonzeroFactorPPM,
config.RechargeBoostWindowMS, config.RechargeBoostFactorPPM, jackpotMultiplierPPMsJSON,
config.JackpotGlobalRTPMaxPPM, config.JackpotUserDayRTPMaxPPM, config.JackpotUser72hRTPMaxPPM,
config.JackpotSpendThresholdCoins, config.MaxJackpotHitsPerUserDay,
config.MaxSinglePayout, config.UserHourlyPayoutCap, config.UserDailyPayoutCap,
config.DeviceDailyPayoutCap, config.RoomHourlyPayoutCap, config.AnchorDailyPayoutCap,
); err != nil { ); err != nil {
return domain.RuleConfig{}, err return domain.RuleConfig{}, err
} }
@ -77,23 +113,40 @@ func (r *Repository) PublishLuckyGiftRuleConfig(ctx context.Context, config doma
for _, tier := range stage.Tiers { for _, tier := range stage.Tiers {
if _, err := tx.ExecContext(ctx, ` if _, err := tx.ExecContext(ctx, `
INSERT INTO lucky_gift_stage_tiers ( INSERT INTO lucky_gift_stage_tiers (
app_code, pool_id, rule_version, stage, tier_id, multiplier_ppm, base_weight_ppm, app_code, pool_id, rule_version, stage, min_recharge_7d_coins, min_recharge_30d_coins,
tier_id, multiplier_ppm, base_weight_ppm,
high_water_only, enabled high_water_only, enabled
) VALUES (?,?,?,?,?,?,?,?,?)`, ) VALUES (?,?,?,?,?,?,?,?,?,?,?)`,
config.AppCode, config.PoolID, config.RuleVersion, tier.Stage, tier.TierID, tier.MultiplierPPM, tier.BaseWeightPPM, config.AppCode, config.PoolID, config.RuleVersion, tier.Stage, stage.MinRecharge7DCoins, stage.MinRecharge30DCoins,
tier.TierID, tier.MultiplierPPM, tier.BaseWeightPPM,
tier.HighWaterOnly, tier.Enabled, tier.HighWaterOnly, tier.Enabled,
); err != nil { ); err != nil {
return domain.RuleConfig{}, err return domain.RuleConfig{}, err
} }
} }
} }
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 { if err := tx.Commit(); err != nil {
return domain.RuleConfig{}, err return domain.RuleConfig{}, err
} }
return config, nil return config, nil
} }
// ListLuckyGiftRuleConfigs 返回每个奖池的最新 v2 规则版本,用于后台奖池列表。 // ListLuckyGiftRuleConfigs 返回每个奖池的最新规则版本,用于后台奖池列表。
func (r *Repository) ListLuckyGiftRuleConfigs(ctx context.Context, appCode string) ([]domain.RuleConfig, error) { func (r *Repository) ListLuckyGiftRuleConfigs(ctx context.Context, appCode string) ([]domain.RuleConfig, error) {
if r == nil || r.db == nil { if r == nil || r.db == nil {
return nil, xerr.New(xerr.Unavailable, "mysql repository is not configured") return nil, xerr.New(xerr.Unavailable, "mysql repository is not configured")
@ -105,10 +158,19 @@ func (r *Repository) ListLuckyGiftRuleConfigs(ctx context.Context, appCode strin
args = append(args, appcode.Normalize(appCode)) args = append(args, appcode.Normalize(appCode))
} }
rows, err := r.db.QueryContext(ctx, ` rows, err := r.db.QueryContext(ctx, `
SELECT v.app_code, v.pool_id, v.rule_version, v.enabled, v.target_rtp_ppm, v.pool_rate_ppm, SELECT v.app_code, v.pool_id, v.rule_version, v.enabled, v.strategy_version, v.target_rtp_ppm, v.pool_rate_ppm,
v.profit_rate_ppm, v.anchor_rate_ppm,
v.settlement_window_wager, v.control_band_ppm, v.gift_price_reference, v.settlement_window_wager, v.control_band_ppm, v.gift_price_reference,
v.novice_max_equivalent_draws, v.normal_max_equivalent_draws, v.novice_max_equivalent_draws, v.normal_max_equivalent_draws,
v.effective_from_ms, v.created_by_admin_id, v.created_at_ms v.effective_from_ms, v.created_by_admin_id, v.created_at_ms,
v.initial_pool_coins, v.loss_streak_guarantee,
v.low_watermark_coins, v.low_water_nonzero_factor_ppm,
v.high_watermark_coins, v.high_water_nonzero_factor_ppm,
v.recharge_boost_window_ms, v.recharge_boost_factor_ppm, v.jackpot_multiplier_ppms,
v.jackpot_global_rtp_max_ppm, v.jackpot_user_day_rtp_max_ppm, v.jackpot_user_72h_rtp_max_ppm,
v.jackpot_spend_threshold_coins, v.max_jackpot_hits_per_user_day,
v.max_single_payout, v.user_hourly_payout_cap, v.user_daily_payout_cap,
v.device_daily_payout_cap, v.room_hourly_payout_cap, v.anchor_daily_payout_cap
FROM lucky_gift_rule_versions v FROM lucky_gift_rule_versions v
INNER JOIN ( INNER JOIN (
SELECT app_code, pool_id, MAX(rule_version) AS rule_version SELECT app_code, pool_id, MAX(rule_version) AS rule_version
@ -148,10 +210,19 @@ func (r *Repository) getLuckyGiftRuleConfig(ctx context.Context, q luckyRuleQuer
lockSQL = " FOR UPDATE" lockSQL = " FOR UPDATE"
} }
row := q.QueryRowContext(ctx, ` row := q.QueryRowContext(ctx, `
SELECT app_code, pool_id, rule_version, enabled, target_rtp_ppm, pool_rate_ppm, SELECT app_code, pool_id, rule_version, enabled, strategy_version, target_rtp_ppm, pool_rate_ppm,
profit_rate_ppm, anchor_rate_ppm,
settlement_window_wager, control_band_ppm, gift_price_reference, settlement_window_wager, control_band_ppm, gift_price_reference,
novice_max_equivalent_draws, normal_max_equivalent_draws, novice_max_equivalent_draws, normal_max_equivalent_draws,
effective_from_ms, created_by_admin_id, created_at_ms effective_from_ms, created_by_admin_id, created_at_ms,
initial_pool_coins, loss_streak_guarantee,
low_watermark_coins, low_water_nonzero_factor_ppm,
high_watermark_coins, high_water_nonzero_factor_ppm,
recharge_boost_window_ms, recharge_boost_factor_ppm, jackpot_multiplier_ppms,
jackpot_global_rtp_max_ppm, jackpot_user_day_rtp_max_ppm, jackpot_user_72h_rtp_max_ppm,
jackpot_spend_threshold_coins, max_jackpot_hits_per_user_day,
max_single_payout, user_hourly_payout_cap, user_daily_payout_cap,
device_daily_payout_cap, room_hourly_payout_cap, anchor_daily_payout_cap
FROM lucky_gift_rule_versions FROM lucky_gift_rule_versions
WHERE app_code = ? AND pool_id = ? WHERE app_code = ? AND pool_id = ?
ORDER BY rule_version DESC ORDER BY rule_version DESC
@ -175,7 +246,8 @@ func (r *Repository) getLuckyGiftRuleConfig(ctx context.Context, q luckyRuleQuer
func (r *Repository) getLuckyGiftRuleStages(ctx context.Context, q luckyRuleQueryer, appCode string, poolID string, ruleVersion int64) ([]domain.RuleStage, error) { func (r *Repository) getLuckyGiftRuleStages(ctx context.Context, q luckyRuleQueryer, appCode string, poolID string, ruleVersion int64) ([]domain.RuleStage, error) {
rows, err := q.QueryContext(ctx, ` rows, err := q.QueryContext(ctx, `
SELECT stage, tier_id, multiplier_ppm, base_weight_ppm, high_water_only, enabled SELECT stage, min_recharge_7d_coins, min_recharge_30d_coins,
tier_id, multiplier_ppm, base_weight_ppm, high_water_only, enabled
FROM lucky_gift_stage_tiers FROM lucky_gift_stage_tiers
WHERE app_code = ? AND pool_id = ? AND rule_version = ? WHERE app_code = ? AND pool_id = ? AND rule_version = ?
ORDER BY FIELD(stage, 'novice', 'normal', 'advanced'), multiplier_ppm ASC, tier_id ASC`, ORDER BY FIELD(stage, 'novice', 'normal', 'advanced'), multiplier_ppm ASC, tier_id ASC`,
@ -189,14 +261,31 @@ func (r *Repository) getLuckyGiftRuleStages(ctx context.Context, q luckyRuleQuer
stages := make([]domain.RuleStage, 0, 3) stages := make([]domain.RuleStage, 0, 3)
for rows.Next() { for rows.Next() {
var tier domain.RuleTier var tier domain.RuleTier
if err := rows.Scan(&tier.Stage, &tier.TierID, &tier.MultiplierPPM, &tier.BaseWeightPPM, &tier.HighWaterOnly, &tier.Enabled); err != nil { var minRecharge7DCoins, minRecharge30DCoins int64
if err := rows.Scan(
&tier.Stage,
&minRecharge7DCoins,
&minRecharge30DCoins,
&tier.TierID,
&tier.MultiplierPPM,
&tier.BaseWeightPPM,
&tier.HighWaterOnly,
&tier.Enabled,
); err != nil {
return nil, err return nil, err
} }
index, exists := stageIndex[tier.Stage] index, exists := stageIndex[tier.Stage]
if !exists { if !exists {
index = len(stages) index = len(stages)
stageIndex[tier.Stage] = index stageIndex[tier.Stage] = index
stages = append(stages, domain.RuleStage{Stage: tier.Stage}) stages = append(stages, domain.RuleStage{
Stage: tier.Stage,
MinRecharge7DCoins: minRecharge7DCoins,
MinRecharge30DCoins: minRecharge30DCoins,
})
} else if stages[index].MinRecharge7DCoins != minRecharge7DCoins || stages[index].MinRecharge30DCoins != minRecharge30DCoins {
// 阈值按 stage 定义但为兼容旧表结构随 tier 冗余保存;同阶段不一致表示数据已被绕过发布入口篡改,不能静默选第一行。
return nil, fmt.Errorf("inconsistent recharge thresholds for lucky gift stage %s", tier.Stage)
} }
stages[index].Tiers = append(stages[index].Tiers, tier) stages[index].Tiers = append(stages[index].Tiers, tier)
} }
@ -205,13 +294,17 @@ func (r *Repository) getLuckyGiftRuleStages(ctx context.Context, q luckyRuleQuer
func scanLuckyGiftRuleConfig(scanner luckyScanner) (domain.RuleConfig, error) { func scanLuckyGiftRuleConfig(scanner luckyScanner) (domain.RuleConfig, error) {
var config domain.RuleConfig var config domain.RuleConfig
var jackpotMultiplierPPMsJSON []byte
if err := scanner.Scan( if err := scanner.Scan(
&config.AppCode, &config.AppCode,
&config.PoolID, &config.PoolID,
&config.RuleVersion, &config.RuleVersion,
&config.Enabled, &config.Enabled,
&config.StrategyVersion,
&config.TargetRTPPPM, &config.TargetRTPPPM,
&config.PoolRatePPM, &config.PoolRatePPM,
&config.ProfitRatePPM,
&config.AnchorRatePPM,
&config.SettlementWindowWager, &config.SettlementWindowWager,
&config.ControlBandPPM, &config.ControlBandPPM,
&config.GiftPriceReference, &config.GiftPriceReference,
@ -220,8 +313,40 @@ func scanLuckyGiftRuleConfig(scanner luckyScanner) (domain.RuleConfig, error) {
&config.EffectiveFromMS, &config.EffectiveFromMS,
&config.CreatedByAdminID, &config.CreatedByAdminID,
&config.CreatedAtMS, &config.CreatedAtMS,
&config.InitialPoolCoins,
&config.LossStreakGuarantee,
&config.LowWatermarkCoins,
&config.LowWaterNonzeroFactorPPM,
&config.HighWatermarkCoins,
&config.HighWaterNonzeroFactorPPM,
&config.RechargeBoostWindowMS,
&config.RechargeBoostFactorPPM,
&jackpotMultiplierPPMsJSON,
&config.JackpotGlobalRTPMaxPPM,
&config.JackpotUserDayRTPMaxPPM,
&config.JackpotUser72hRTPMaxPPM,
&config.JackpotSpendThresholdCoins,
&config.MaxJackpotHitsPerUserDay,
&config.MaxSinglePayout,
&config.UserHourlyPayoutCap,
&config.UserDailyPayoutCap,
&config.DeviceDailyPayoutCap,
&config.RoomHourlyPayoutCap,
&config.AnchorDailyPayoutCap,
); err != nil { ); err != nil {
return domain.RuleConfig{}, err return domain.RuleConfig{}, err
} }
config.StrategyVersion = strings.ToLower(strings.TrimSpace(config.StrategyVersion))
if config.StrategyVersion == "" {
config.StrategyVersion = domain.StrategyFixedV2
}
if len(jackpotMultiplierPPMsJSON) > 0 {
if err := json.Unmarshal(jackpotMultiplierPPMsJSON, &config.JackpotMultiplierPPMs); err != nil {
return domain.RuleConfig{}, fmt.Errorf("decode lucky gift jackpot multipliers: %w", err)
}
}
if config.JackpotMultiplierPPMs == nil {
config.JackpotMultiplierPPMs = []int64{}
}
return config, nil return config, nil
} }

View File

@ -45,6 +45,9 @@ func TestPublishLuckyGiftRuleConfigPersistsVersionsAndTiers(t *testing.T) {
if published.RuleVersion != 1 { if published.RuleVersion != 1 {
t.Fatalf("first publish rule_version = %d, want 1", published.RuleVersion) t.Fatalf("first publish rule_version = %d, want 1", published.RuleVersion)
} }
if published.StrategyVersion != domain.StrategyFixedV2 {
t.Fatalf("legacy request strategy = %q, want fixed_v2", published.StrategyVersion)
}
// 版本必须只增不改:同奖池再次发布要拿到 v2且两版规则和奖档都能按最新版本读回。 // 版本必须只增不改:同奖池再次发布要拿到 v2且两版规则和奖档都能按最新版本读回。
again, err := repo.PublishLuckyGiftRuleConfig(ctx, config, 1_700_000_100_000) again, err := repo.PublishLuckyGiftRuleConfig(ctx, config, 1_700_000_100_000)
@ -69,4 +72,184 @@ func TestPublishLuckyGiftRuleConfigPersistsVersionsAndTiers(t *testing.T) {
if len(latest.Stages) != 1 || len(latest.Stages[0].Tiers) != 1 || latest.Stages[0].Tiers[0].TierID != "novice_1x" { if len(latest.Stages) != 1 || len(latest.Stages[0].Tiers) != 1 || latest.Stages[0].Tiers[0].TierID != "novice_1x" {
t.Fatalf("latest stages = %+v, want single novice tier novice_1x", latest.Stages) t.Fatalf("latest stages = %+v, want single novice tier novice_1x", latest.Stages)
} }
// dynamic_v3 的 JSON 倍率、阶段充值阈值和金额风控必须随不可变版本完整往返;否则服务重启后会静默退回默认策略。
dynamic := config
dynamic.StrategyVersion = domain.StrategyDynamicV3
dynamic.ProfitRatePPM = 10_000
dynamic.AnchorRatePPM = 10_000
dynamic.InitialPoolCoins = 0
dynamic.LossStreakGuarantee = 5
dynamic.LowWatermarkCoins = 10_000_000
dynamic.LowWaterNonzeroFactorPPM = 700_000
dynamic.HighWatermarkCoins = 20_000_000
dynamic.HighWaterNonzeroFactorPPM = 1_300_000
dynamic.RechargeBoostWindowMS = 300_000
dynamic.RechargeBoostFactorPPM = 1_100_000
dynamic.JackpotMultiplierPPMs = []int64{200_000_000, 500_000_000, 1_000_000_000}
dynamic.JackpotGlobalRTPMaxPPM = 980_000
dynamic.JackpotUserDayRTPMaxPPM = 960_000
dynamic.JackpotUser72hRTPMaxPPM = 960_000
dynamic.JackpotSpendThresholdCoins = 5_000
dynamic.MaxJackpotHitsPerUserDay = 5
dynamic.MaxSinglePayout = 1_000_000
dynamic.UserHourlyPayoutCap = 2_000_000
dynamic.UserDailyPayoutCap = 3_000_000
dynamic.DeviceDailyPayoutCap = 4_000_000
dynamic.RoomHourlyPayoutCap = 5_000_000
dynamic.AnchorDailyPayoutCap = 6_000_000
dynamic.Stages[0].MinRecharge7DCoins = 1
dynamic.Stages[0].MinRecharge30DCoins = 2
third, err := repo.PublishLuckyGiftRuleConfig(ctx, dynamic, 1_700_000_200_000)
if err != nil {
t.Fatalf("publish dynamic rule version: %v", err)
}
if third.RuleVersion != 3 {
t.Fatalf("dynamic rule_version = %d, want 3", third.RuleVersion)
}
loaded, exists, err := repo.GetLuckyGiftRuleConfig(ctx, "lucky")
if err != nil || !exists {
t.Fatalf("get dynamic rule: exists=%v err=%v", exists, err)
}
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 {
t.Fatalf("jackpot multipliers = %v, want [200x 500x 1000x] ppm", loaded.JackpotMultiplierPPMs)
}
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) {
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",
})
migrationPath := mysqlschema.InitDBPath(t, caller, "..", "..", "..", "deploy", "mysql", "migrations", "004_dynamic_strategy.sql")
// initdb 已包含最终结构004 连续运行两次仍必须成功,覆盖全新环境与线上重复发布 migration 两条路径。
runLuckyGiftSQLScript(t, schema.DB, migrationPath)
runLuckyGiftSQLScript(t, schema.DB, migrationPath)
for _, table := range []string{
"lucky_risk_counters", "lucky_user_rtp_hour_buckets", "lucky_user_rtp_boundary_events",
"lucky_user_strategy_days", "lucky_gift_command_locks", "external_lucky_gift_draw_items", "external_lucky_gift_request_locks",
} {
var count int
if err := schema.DB.QueryRow(`
SELECT COUNT(*) FROM information_schema.TABLES
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ?`, table).Scan(&count); err != nil || count != 1 {
t.Fatalf("migration table %s: count=%d err=%v", table, count, err)
}
}
for _, column := range []struct {
table string
name string
minLength int64
}{
{table: "lucky_user_states", name: "pending_jackpot_tokens"},
{table: "lucky_rtp_windows", name: "scope_id", minLength: 128},
{table: "lucky_risk_counters", name: "scope_id", minLength: 255},
{table: "lucky_gift_command_locks", name: "command_id", minLength: 128},
} {
var count, length int64
if err := schema.DB.QueryRow(`
SELECT COUNT(*), COALESCE(MAX(CHARACTER_MAXIMUM_LENGTH), 0)
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND COLUMN_NAME = ?`,
column.table, column.name,
).Scan(&count, &length); err != nil || count != 1 || (column.minLength > 0 && length < column.minLength) {
t.Fatalf("migration column %s.%s count=%d length=%d err=%v", column.table, column.name, count, length, err)
}
}
// 旧发布器只写 v2 原列;数据库默认值和扫描器必须共同把该行解释为 fixed_v2而不是要求动态字段。
if _, err := schema.DB.Exec(`
INSERT INTO lucky_gift_rule_versions (
app_code, pool_id, rule_version, enabled, target_rtp_ppm, pool_rate_ppm,
settlement_window_wager, control_band_ppm, gift_price_reference,
novice_max_equivalent_draws, normal_max_equivalent_draws,
effective_from_ms, created_by_admin_id, created_at_ms
) VALUES ('legacy', 'lucky', 1, 1, 950000, 960000, 1000000, 10000, 100, 2000, 20000, 1, 7, 1)`); err != nil {
t.Fatalf("insert pre-dynamic rule row: %v", err)
}
repo := &Repository{db: schema.DB}
legacy, exists, err := repo.GetLuckyGiftRuleConfig(appcode.WithContext(context.Background(), "legacy"), "lucky")
if err != nil || !exists {
t.Fatalf("read pre-dynamic rule row: exists=%v err=%v", exists, err)
}
if legacy.StrategyVersion != domain.StrategyFixedV2 || len(legacy.JackpotMultiplierPPMs) != 0 {
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

@ -40,6 +40,7 @@ func (s *LuckyGiftServer) CheckLuckyGift(ctx context.Context, req *luckygiftv1.C
GiftId: result.GiftID, GiftId: result.GiftID,
GiftPrice: result.GiftPrice, GiftPrice: result.GiftPrice,
RuleVersion: result.RuleVersion, RuleVersion: result.RuleVersion,
StrategyVersion: result.StrategyVersion,
TargetRtpPpm: result.TargetRTPPPM, TargetRtpPpm: result.TargetRTPPPM,
ExperiencePool: result.ExperiencePool, ExperiencePool: result.ExperiencePool,
}, nil }, nil
@ -88,6 +89,7 @@ func (s *LuckyGiftServer) ExecuteExternalGiftDraw(ctx context.Context, req *luck
result, err := s.svc.ExecuteExternalDraw(ctx, domain.ExternalDrawCommand{ result, err := s.svc.ExecuteExternalDraw(ctx, domain.ExternalDrawCommand{
AppCode: req.GetAppCode(), AppCode: req.GetAppCode(),
ExternalUserID: req.GetExternalUserId(), ExternalUserID: req.GetExternalUserId(),
DeviceID: req.GetDeviceId(),
RequestID: req.GetRequestId(), RequestID: req.GetRequestId(),
GiftCount: req.GetGiftCount(), GiftCount: req.GetGiftCount(),
UnitAmount: req.GetUnitAmount(), UnitAmount: req.GetUnitAmount(),
@ -122,6 +124,12 @@ func luckyDrawCommandFromProto(meta *luckygiftv1.LuckyGiftMeta) domain.DrawComma
SenderAvatar: meta.GetSenderAvatar(), SenderAvatar: meta.GetSenderAvatar(),
SenderDisplayUserID: meta.GetSenderDisplayUserId(), SenderDisplayUserID: meta.GetSenderDisplayUserId(),
SenderPrettyDisplayUserID: meta.GetSenderPrettyDisplayUserId(), SenderPrettyDisplayUserID: meta.GetSenderPrettyDisplayUserId(),
// 充值分层、短时加权与主播风控只消费 wallet-service 已落账的事实,
// lucky-gift-service 不跨 owner 数据库反查并重新解释同一笔交易。
Recharge7DCoins: meta.GetRecharge_7DCoins(),
Recharge30DCoins: meta.GetRecharge_30DCoins(),
LastRechargedAtMS: meta.GetLastRechargedAtMs(),
GiftIncomeCoins: meta.GetGiftIncomeCoins(),
} }
} }
@ -222,6 +230,7 @@ func (s *AdminLuckyGiftServer) ListLuckyGiftPoolBalances(ctx context.Context, re
pools, err := s.svc.ListPoolBalances(ctx, domain.PoolBalanceQuery{ pools, err := s.svc.ListPoolBalances(ctx, domain.PoolBalanceQuery{
AppCode: appCode, AppCode: appCode,
PoolID: req.GetPoolId(), PoolID: req.GetPoolId(),
StrategyVersion: req.GetStrategyVersion(),
}) })
if err != nil { if err != nil {
return nil, xerr.ToGRPCError(err) return nil, xerr.ToGRPCError(err)
@ -233,6 +242,32 @@ func (s *AdminLuckyGiftServer) ListLuckyGiftPoolBalances(ctx context.Context, re
return resp, nil 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 { func luckyRuleConfigFromProto(config *luckygiftv1.LuckyGiftRuleConfig) domain.RuleConfig {
if config == nil { if config == nil {
return domain.RuleConfig{} return domain.RuleConfig{}
@ -252,6 +287,8 @@ func luckyRuleConfigFromProto(config *luckygiftv1.LuckyGiftRuleConfig) domain.Ru
} }
stages = append(stages, domain.RuleStage{ stages = append(stages, domain.RuleStage{
Stage: stage.GetStage(), Stage: stage.GetStage(),
MinRecharge7DCoins: stage.GetMinRecharge_7DCoins(),
MinRecharge30DCoins: stage.GetMinRecharge_30DCoins(),
Tiers: tiers, Tiers: tiers,
}) })
} }
@ -270,6 +307,29 @@ func luckyRuleConfigFromProto(config *luckygiftv1.LuckyGiftRuleConfig) domain.Ru
EffectiveFromMS: config.GetEffectiveFromMs(), EffectiveFromMS: config.GetEffectiveFromMs(),
CreatedByAdminID: config.GetCreatedByAdminId(), CreatedByAdminID: config.GetCreatedByAdminId(),
CreatedAtMS: config.GetCreatedAtMs(), CreatedAtMS: config.GetCreatedAtMs(),
StrategyVersion: config.GetStrategyVersion(),
ProfitRatePPM: config.GetProfitRatePpm(),
AnchorRatePPM: config.GetAnchorRatePpm(),
InitialPoolCoins: config.GetInitialPoolCoins(),
LossStreakGuarantee: config.GetLossStreakGuarantee(),
LowWatermarkCoins: config.GetLowWatermarkCoins(),
LowWaterNonzeroFactorPPM: config.GetLowWaterNonzeroFactorPpm(),
HighWatermarkCoins: config.GetHighWatermarkCoins(),
HighWaterNonzeroFactorPPM: config.GetHighWaterNonzeroFactorPpm(),
RechargeBoostWindowMS: config.GetRechargeBoostWindowMs(),
RechargeBoostFactorPPM: config.GetRechargeBoostFactorPpm(),
JackpotMultiplierPPMs: append([]int64(nil), config.GetJackpotMultiplierPpms()...),
JackpotGlobalRTPMaxPPM: config.GetJackpotGlobalRtpMaxPpm(),
JackpotUserDayRTPMaxPPM: config.GetJackpotUserDayRtpMaxPpm(),
JackpotUser72hRTPMaxPPM: config.GetJackpotUser_72HRtpMaxPpm(),
JackpotSpendThresholdCoins: config.GetJackpotSpendThresholdCoins(),
MaxJackpotHitsPerUserDay: config.GetMaxJackpotHitsPerUserDay(),
MaxSinglePayout: config.GetMaxSinglePayout(),
UserHourlyPayoutCap: config.GetUserHourlyPayoutCap(),
UserDailyPayoutCap: config.GetUserDailyPayoutCap(),
DeviceDailyPayoutCap: config.GetDeviceDailyPayoutCap(),
RoomHourlyPayoutCap: config.GetRoomHourlyPayoutCap(),
AnchorDailyPayoutCap: config.GetAnchorDailyPayoutCap(),
Stages: stages, Stages: stages,
} }
} }
@ -290,6 +350,8 @@ func luckyRuleConfigToProto(config domain.RuleConfig) *luckygiftv1.LuckyGiftRule
} }
stages = append(stages, &luckygiftv1.LuckyGiftRuleStage{ stages = append(stages, &luckygiftv1.LuckyGiftRuleStage{
Stage: stage.Stage, Stage: stage.Stage,
MinRecharge_7DCoins: stage.MinRecharge7DCoins,
MinRecharge_30DCoins: stage.MinRecharge30DCoins,
Tiers: tiers, Tiers: tiers,
}) })
} }
@ -308,6 +370,29 @@ func luckyRuleConfigToProto(config domain.RuleConfig) *luckygiftv1.LuckyGiftRule
EffectiveFromMs: config.EffectiveFromMS, EffectiveFromMs: config.EffectiveFromMS,
CreatedByAdminId: config.CreatedByAdminID, CreatedByAdminId: config.CreatedByAdminID,
CreatedAtMs: config.CreatedAtMS, CreatedAtMs: config.CreatedAtMS,
StrategyVersion: config.StrategyVersion,
ProfitRatePpm: config.ProfitRatePPM,
AnchorRatePpm: config.AnchorRatePPM,
InitialPoolCoins: config.InitialPoolCoins,
LossStreakGuarantee: config.LossStreakGuarantee,
LowWatermarkCoins: config.LowWatermarkCoins,
LowWaterNonzeroFactorPpm: config.LowWaterNonzeroFactorPPM,
HighWatermarkCoins: config.HighWatermarkCoins,
HighWaterNonzeroFactorPpm: config.HighWaterNonzeroFactorPPM,
RechargeBoostWindowMs: config.RechargeBoostWindowMS,
RechargeBoostFactorPpm: config.RechargeBoostFactorPPM,
JackpotMultiplierPpms: append([]int64(nil), config.JackpotMultiplierPPMs...),
JackpotGlobalRtpMaxPpm: config.JackpotGlobalRTPMaxPPM,
JackpotUserDayRtpMaxPpm: config.JackpotUserDayRTPMaxPPM,
JackpotUser_72HRtpMaxPpm: config.JackpotUser72hRTPMaxPPM,
JackpotSpendThresholdCoins: config.JackpotSpendThresholdCoins,
MaxJackpotHitsPerUserDay: config.MaxJackpotHitsPerUserDay,
MaxSinglePayout: config.MaxSinglePayout,
UserHourlyPayoutCap: config.UserHourlyPayoutCap,
UserDailyPayoutCap: config.UserDailyPayoutCap,
DeviceDailyPayoutCap: config.DeviceDailyPayoutCap,
RoomHourlyPayoutCap: config.RoomHourlyPayoutCap,
AnchorDailyPayoutCap: config.AnchorDailyPayoutCap,
Stages: stages, Stages: stages,
} }
} }
@ -384,12 +469,31 @@ func luckyPoolBalanceToProto(pool domain.PoolBalance) *luckygiftv1.LuckyGiftPool
return &luckygiftv1.LuckyGiftPoolBalance{ return &luckygiftv1.LuckyGiftPoolBalance{
AppCode: pool.AppCode, AppCode: pool.AppCode,
PoolId: pool.PoolID, PoolId: pool.PoolID,
StrategyVersion: pool.StrategyVersion,
Balance: pool.Balance, Balance: pool.Balance,
ReserveFloor: pool.ReserveFloor, ReserveFloor: pool.ReserveFloor,
AvailableBalance: pool.AvailableBalance, AvailableBalance: pool.AvailableBalance,
TotalIn: pool.TotalIn, TotalIn: pool.TotalIn,
TotalOut: pool.TotalOut, TotalOut: pool.TotalOut,
ManualCreditTotal: pool.ManualCreditTotal,
ManualDebitTotal: pool.ManualDebitTotal,
Materialized: pool.Materialized, Materialized: pool.Materialized,
UpdatedAtMs: pool.UpdatedAtMS, 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" "testing"
luckygiftv1 "hyapp.local/api/proto/luckygift/v1" 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) { func TestBatchExecuteLuckyGiftDrawRejectsMixedAppCodes(t *testing.T) {
server := NewLuckyGiftServer(nil) server := NewLuckyGiftServer(nil)
_, err := server.BatchExecuteLuckyGiftDraw(context.Background(), &luckygiftv1.BatchExecuteLuckyGiftDrawRequest{ _, 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") 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)
}
}

View File

@ -34,6 +34,9 @@ type Base struct {
GatewayNodeID string `json:"gateway_node_id"` GatewayNodeID string `json:"gateway_node_id"`
// SessionID 记录 gateway 看到的客户端会话,不等同于腾讯云 IM 连接。 // SessionID 记录 gateway 看到的客户端会话,不等同于腾讯云 IM 连接。
SessionID string `json:"session_id"` SessionID string `json:"session_id"`
// DeviceID 是 user-service 绑定到 auth_session 并经 JWT 验签的设备 ID。
// 旧 command log 没有该字段时反序列化为空fixed_v2 仍可重放dynamic_v3 由抽奖 owner fail-close。
DeviceID string `json:"device_id,omitempty"`
// SentAtMS 是客户端请求进入系统的时间,恢复回放时用于还原用户态时间字段。 // SentAtMS 是客户端请求进入系统的时间,恢复回放时用于还原用户态时间字段。
SentAtMS int64 `json:"sent_at_ms"` SentAtMS int64 `json:"sent_at_ms"`
} }
@ -439,6 +442,9 @@ type SendGift struct {
GiftID string `json:"gift_id"` GiftID string `json:"gift_id"`
// PoolID 是幸运礼物奖池标识;为空表示普通礼物或默认幸运奖池。 // PoolID 是幸运礼物奖池标识;为空表示普通礼物或默认幸运奖池。
PoolID string `json:"pool_id,omitempty"` PoolID string `json:"pool_id,omitempty"`
// LuckyGiftStrategyVersion 是 lucky-gift Check 返回的 owner 规则快照room saga 固化它只为
// 在滚动发布期间区分 fixed_v2 兼容与 dynamic_v3 必填 wallet 事实,不参与客户端幂等语义。
LuckyGiftStrategyVersion string `json:"lucky_gift_strategy_version,omitempty"`
// GiftCount 是礼物数量,必须为正数。 // GiftCount 是礼物数量,必须为正数。
GiftCount int32 `json:"gift_count"` GiftCount int32 `json:"gift_count"`
// EntitlementID 是背包送礼要消耗的用户礼物权益 ID普通金币送礼为空。 // EntitlementID 是背包送礼要消耗的用户礼物权益 ID普通金币送礼为空。
@ -652,6 +658,9 @@ func IdempotencyPayload(payload []byte) ([]byte, error) {
delete(values, "request_id") delete(values, "request_id")
delete(values, "gateway_node_id") delete(values, "gateway_node_id")
delete(values, "session_id") delete(values, "session_id")
// device_id 是首次请求的可信风控快照,会在 gift operation/command log 中按原值恢复;
// 它不属于客户端业务载荷token 旋转后的同 command_id 重试不应因此冲突。
delete(values, "device_id")
delete(values, "sent_at_ms") delete(values, "sent_at_ms")
// 展示快照来自 gateway 对 user-service 的读取结果,不改变扣费和房间状态语义;重试同一 command_id 时不能因为昵称头像变化冲突。 // 展示快照来自 gateway 对 user-service 的读取结果,不改变扣费和房间状态语义;重试同一 command_id 时不能因为昵称头像变化冲突。
delete(values, "sender_display_profile") delete(values, "sender_display_profile")
@ -685,6 +694,7 @@ func IdempotencyPayload(payload []byte) ([]byte, error) {
delete(values, "gift_type_code") delete(values, "gift_type_code")
delete(values, "host_period_diamond_added") delete(values, "host_period_diamond_added")
delete(values, "host_period_cycle_key") delete(values, "host_period_cycle_key")
delete(values, "lucky_gift_strategy_version")
delete(values, "rocket_touched") delete(values, "rocket_touched")
delete(values, "rocket_added_fuel") delete(values, "rocket_added_fuel")
delete(values, "rocket_effective_fuel") delete(values, "rocket_effective_fuel")

View File

@ -7,7 +7,7 @@ import (
func TestSendGiftIdempotencyIgnoresGatewayResolvedSnapshots(t *testing.T) { func TestSendGiftIdempotencyIgnoresGatewayResolvedSnapshots(t *testing.T) {
base := SendGift{ base := SendGift{
Base: Base{CommandID: "combo-1_b1", Room: "room-1", ActorID: 42}, Base: Base{CommandID: "combo-1_b1", Room: "room-1", ActorID: 42, DeviceID: "device-first"},
TargetType: "user", TargetUserIDs: []int64{43}, TargetUserID: 43, TargetType: "user", TargetUserIDs: []int64{43}, TargetUserID: 43,
GiftID: "rose", GiftCount: 9, ComboSessionID: "combo-1", BatchSeq: 1, GiftID: "rose", GiftCount: 9, ComboSessionID: "combo-1", BatchSeq: 1,
SenderRegionID: 1001, SenderCountryID: 840, SenderRegionID: 1001, SenderCountryID: 840,
@ -19,6 +19,7 @@ func TestSendGiftIdempotencyIgnoresGatewayResolvedSnapshots(t *testing.T) {
retry.SenderCountryID = 356 retry.SenderCountryID = 356
retry.TargetHostScopes = []GiftTargetHostScope{{TargetUserID: 43, TargetIsHost: false}} retry.TargetHostScopes = []GiftTargetHostScope{{TargetUserID: 43, TargetIsHost: false}}
retry.SenderDisplayProfile = GiftDisplayProfile{UserID: 42, Username: "after"} retry.SenderDisplayProfile = GiftDisplayProfile{UserID: 42, Username: "after"}
retry.DeviceID = "device-after-token-rotation"
left, err := IdempotencyPayloadForCommand(base) left, err := IdempotencyPayloadForCommand(base)
if err != nil { if err != nil {
@ -33,6 +34,40 @@ func TestSendGiftIdempotencyIgnoresGatewayResolvedSnapshots(t *testing.T) {
} }
} }
func TestSendGiftCommandLogPreservesTrustedDeviceIDAndReadsLegacyPayload(t *testing.T) {
original := SendGift{
Base: Base{
AppCode: "lalu", CommandID: "cmd-device-recovery", Room: "room-1", ActorID: 42,
SessionID: "sess-not-a-device", DeviceID: "device-auth-session-1", SentAtMS: 1_000,
},
TargetType: "user", TargetUserIDs: []int64{43}, TargetUserID: 43,
GiftID: "rose", GiftCount: 1,
}
payload, err := Serialize(original)
if err != nil {
t.Fatalf("serialize send gift device snapshot: %v", err)
}
recovered, err := Deserialize(original.Type(), payload)
if err != nil {
t.Fatalf("deserialize send gift device snapshot: %v", err)
}
got, ok := recovered.(*SendGift)
if !ok || got.DeviceID != "device-auth-session-1" || got.SessionID != "sess-not-a-device" {
t.Fatalf("recovered trusted device snapshot mismatch: %#v", recovered)
}
// 滚动升级前的 command log 不包含 device_id反序列化必须保持可恢复
// 并留下空值让 fixed_v2 兼容、dynamic_v3 在 owner 处明确拒绝。
legacy, err := Deserialize(original.Type(), []byte(`{"app_code":"lalu","command_id":"cmd-legacy","actor_user_id":42,"room_id":"room-1","session_id":"sess-legacy","target_type":"user","target_user_id":43,"gift_id":"rose","gift_count":1}`))
if err != nil {
t.Fatalf("deserialize legacy send gift: %v", err)
}
legacyGift := legacy.(*SendGift)
if legacyGift.DeviceID != "" || legacyGift.SessionID != "sess-legacy" {
t.Fatalf("legacy command must not derive device from session: %+v", legacyGift.Base)
}
}
func TestSendGiftIdempotencyKeepsComboBatchIdentity(t *testing.T) { func TestSendGiftIdempotencyKeepsComboBatchIdentity(t *testing.T) {
base := SendGift{Base: Base{CommandID: "combo-1_b1", Room: "room-1", ActorID: 42}, TargetType: "user", TargetUserIDs: []int64{43}, TargetUserID: 43, GiftID: "rose", GiftCount: 1, ComboSessionID: "combo-1", BatchSeq: 1} base := SendGift{Base: Base{CommandID: "combo-1_b1", Room: "room-1", ActorID: 42}, TargetType: "user", TargetUserIDs: []int64{43}, TargetUserID: 43, GiftID: "rose", GiftCount: 1, ComboSessionID: "combo-1", BatchSeq: 1}
reused := base reused := base

View File

@ -243,6 +243,15 @@ func (f *giftFlow) prepareRoomFacts() error {
if checkResp == nil || !checkResp.GetEnabled() { if checkResp == nil || !checkResp.GetEnabled() {
return xerr.New(xerr.RuleNotActive, "lucky gift rule is not active") return xerr.New(xerr.RuleNotActive, "lucky gift rule is not active")
} }
// 规则版本由 lucky-gift owner 判定并随 room saga 命令固化;恢复阶段不重新 Check
// 防止后台切换策略后把一笔已扣费命令从 fixed_v2 兼容语义改成 dynamic_v3。
f.cmd.LuckyGiftStrategyVersion = normalizeLuckyGiftStrategyVersion(checkResp.GetStrategyVersion())
f.settledCommand.LuckyGiftStrategyVersion = f.cmd.LuckyGiftStrategyVersion
if f.cmd.LuckyGiftStrategyVersion == luckyGiftStrategyDynamicV3 && strings.TrimSpace(f.cmd.DeviceID) == "" {
// 新 dynamic_v3 请求在 wallet 扣费前拒绝旧 JWT避免创建一笔因缺设备作用域而无法完成的扣费 saga。
// 已扣费恢复仍由 lucky-gift owner 的同一校验 fail-close不得用 sid/command_id 补值。
return xerr.New(xerr.InvalidArgument, "device_id is required for dynamic lucky gift")
}
// Check 只说明“允许按该 pool 抽奖”,真实价格、区域可用性和扣费仍以 wallet-service 为准。 // Check 只说明“允许按该 pool 抽奖”,真实价格、区域可用性和扣费仍以 wallet-service 为准。
f.luckyEnabled = true f.luckyEnabled = true
} }
@ -289,7 +298,7 @@ func (f *giftFlow) debitAndSettle(now time.Time) error {
f.luckyEnabled = f.s.shouldDrawLuckyGift(f.cmd.PoolID, f.billing.GetGiftTypeCode()) f.luckyEnabled = f.s.shouldDrawLuckyGift(f.cmd.PoolID, f.billing.GetGiftTypeCode())
} }
if !f.cmd.SyntheticLuckyGift && f.luckyEnabled { if !f.cmd.SyntheticLuckyGift && f.luckyEnabled {
f.luckyGifts, err = f.s.executeLuckyGiftDraws(f.ctx, f.req.GetMeta(), f.cmd, f.roomMeta, f.targetBillings, now) f.luckyGifts, err = f.s.executeLuckyGiftDraws(f.ctx, f.req.GetMeta(), f.cmd, f.roomMeta, f.targetBillings)
if err != nil { if err != nil {
// 抽奖失败时整笔送礼失败并依赖 wallet 幂等重试;不能落普通礼物后丢失幸运礼物抽奖事实。 // 抽奖失败时整笔送礼失败并依赖 wallet 幂等重试;不能落普通礼物后丢失幸运礼物抽奖事实。
return err return err

View File

@ -15,28 +15,37 @@ import (
"hyapp/services/room-service/internal/room/command" "hyapp/services/room-service/internal/room/command"
) )
func (s *Service) executeLuckyGiftDraws(ctx context.Context, meta *roomv1.RequestMeta, cmd command.SendGift, roomMeta RoomMeta, targetBillings []giftTargetBilling, now time.Time) ([]*roomv1.LuckyGiftDrawResult, error) { const (
luckyGiftStrategyFixedV2 = "fixed_v2"
luckyGiftStrategyDynamicV3 = "dynamic_v3"
)
func (s *Service) executeLuckyGiftDraws(ctx context.Context, meta *roomv1.RequestMeta, cmd command.SendGift, roomMeta RoomMeta, targetBillings []giftTargetBilling) ([]*roomv1.LuckyGiftDrawResult, error) {
if len(targetBillings) == 0 { if len(targetBillings) == 0 {
return nil, nil return nil, nil
} }
if len(targetBillings) == 1 { if len(targetBillings) == 1 {
result, err := s.executeLuckyGiftDraw(ctx, meta, cmd, roomMeta, targetBillings[0], now) result, err := s.executeLuckyGiftDraw(ctx, meta, cmd, roomMeta, targetBillings[0])
if err != nil { if err != nil {
return nil, err return nil, err
} }
return []*roomv1.LuckyGiftDrawResult{result}, nil return []*roomv1.LuckyGiftDrawResult{result}, nil
} }
return s.executeLuckyGiftDrawBatch(ctx, meta, cmd, roomMeta, targetBillings, now) return s.executeLuckyGiftDrawBatch(ctx, meta, cmd, roomMeta, targetBillings)
} }
func (s *Service) executeLuckyGiftDraw(ctx context.Context, meta *roomv1.RequestMeta, cmd command.SendGift, roomMeta RoomMeta, targetBilling giftTargetBilling, now time.Time) (*roomv1.LuckyGiftDrawResult, error) { func (s *Service) executeLuckyGiftDraw(ctx context.Context, meta *roomv1.RequestMeta, cmd command.SendGift, roomMeta RoomMeta, targetBilling giftTargetBilling) (*roomv1.LuckyGiftDrawResult, error) {
if targetBilling.Billing == nil { if targetBilling.Billing == nil {
return nil, xerr.New(xerr.Unavailable, "wallet target debit response is empty") return nil, xerr.New(xerr.Unavailable, "wallet target debit response is empty")
} }
// 抽奖必须按每个收礼目标独立落事实command_id、target_user_id 和 coin_spent 都来自该目标的 wallet 子交易。 // 抽奖必须按每个收礼目标独立落事实command_id、target_user_id 和 coin_spent 都来自该目标的 wallet 子交易。
luckyMeta, err := luckyGiftMetaForTarget(ctx, meta, cmd, roomMeta, targetBilling)
if err != nil {
return nil, err
}
drawResp, err := s.luckyGift.ExecuteLuckyGiftDraw(ctx, &luckygiftv1.ExecuteLuckyGiftDrawRequest{ drawResp, err := s.luckyGift.ExecuteLuckyGiftDraw(ctx, &luckygiftv1.ExecuteLuckyGiftDrawRequest{
LuckyGift: luckyGiftMetaForTarget(ctx, meta, cmd, roomMeta, targetBilling, now), LuckyGift: luckyMeta,
}) })
if err != nil { if err != nil {
return nil, err return nil, err
@ -48,14 +57,18 @@ func (s *Service) executeLuckyGiftDraw(ctx context.Context, meta *roomv1.Request
return luckyGiftResultFromProto(drawResp.GetResult(), targetBilling.TargetUserID), nil return luckyGiftResultFromProto(drawResp.GetResult(), targetBilling.TargetUserID), nil
} }
func (s *Service) executeLuckyGiftDrawBatch(ctx context.Context, meta *roomv1.RequestMeta, cmd command.SendGift, roomMeta RoomMeta, targetBillings []giftTargetBilling, now time.Time) ([]*roomv1.LuckyGiftDrawResult, error) { func (s *Service) executeLuckyGiftDrawBatch(ctx context.Context, meta *roomv1.RequestMeta, cmd command.SendGift, roomMeta RoomMeta, targetBillings []giftTargetBilling) ([]*roomv1.LuckyGiftDrawResult, error) {
req := &luckygiftv1.BatchExecuteLuckyGiftDrawRequest{LuckyGifts: make([]*luckygiftv1.LuckyGiftMeta, 0, len(targetBillings))} req := &luckygiftv1.BatchExecuteLuckyGiftDrawRequest{LuckyGifts: make([]*luckygiftv1.LuckyGiftMeta, 0, len(targetBillings))}
for _, targetBilling := range targetBillings { for _, targetBilling := range targetBillings {
if targetBilling.Billing == nil { if targetBilling.Billing == nil {
return nil, xerr.New(xerr.Unavailable, "wallet target debit response is empty") return nil, xerr.New(xerr.Unavailable, "wallet target debit response is empty")
} }
// 多人送礼只跨服务调用一次activity-service 在一个事务内按这个顺序推进锁和抽奖事实。 luckyMeta, err := luckyGiftMetaForTarget(ctx, meta, cmd, roomMeta, targetBilling)
req.LuckyGifts = append(req.LuckyGifts, luckyGiftMetaForTarget(ctx, meta, cmd, roomMeta, targetBilling, now)) if err != nil {
return nil, err
}
// 多人送礼只跨服务调用一次lucky-gift-service 在一个事务内按这个顺序推进锁和抽奖事实。
req.LuckyGifts = append(req.LuckyGifts, luckyMeta)
} }
resp, err := s.luckyGift.BatchExecuteLuckyGiftDraw(ctx, req) resp, err := s.luckyGift.BatchExecuteLuckyGiftDraw(ctx, req)
if err != nil { if err != nil {
@ -74,21 +87,29 @@ func (s *Service) executeLuckyGiftDrawBatch(ctx context.Context, meta *roomv1.Re
return results, nil return results, nil
} }
func luckyGiftMetaForTarget(ctx context.Context, meta *roomv1.RequestMeta, cmd command.SendGift, roomMeta RoomMeta, targetBilling giftTargetBilling, now time.Time) *luckygiftv1.LuckyGiftMeta { func luckyGiftMetaForTarget(ctx context.Context, meta *roomv1.RequestMeta, cmd command.SendGift, roomMeta RoomMeta, targetBilling giftTargetBilling) (*luckygiftv1.LuckyGiftMeta, error) {
paidAtMS := targetBilling.Billing.GetPaidAtMs()
if paidAtMS <= 0 && normalizeLuckyGiftStrategyVersion(cmd.LuckyGiftStrategyVersion) == luckyGiftStrategyDynamicV3 {
// dynamic_v3 所有时间窗口必须归属 wallet owner 的实际交易;缺字段通常表示 wallet 尚未滚动升级,
// 已扣费 saga 保持可恢复并等待 owner 回执补齐,绝不能拿 room mutate/recovery 时钟继续开奖。
return nil, xerr.New(xerr.Unavailable, "wallet paid_at_ms is required for dynamic lucky gift")
}
return &luckygiftv1.LuckyGiftMeta{ return &luckygiftv1.LuckyGiftMeta{
Meta: luckyGiftMetaFromRoom(ctx, meta), Meta: luckyGiftMetaFromRoom(ctx, meta),
CommandId: targetBilling.CommandID, CommandId: targetBilling.CommandID,
UserId: cmd.ActorUserID(), UserId: cmd.ActorUserID(),
TargetUserId: targetBilling.TargetUserID, TargetUserId: targetBilling.TargetUserID,
// 目前没有独立设备 ID 字段,优先用 session_id没有时退回目标子 command_id保证每次抽奖 scope 不为空。 // DeviceID 已在首次请求进入 gift operation 时随 command.Base 持久化,恢复必须继续使用该值。
DeviceId: luckyGiftDeviceID(cmd, targetBilling.CommandID), // 空值原样传给 ownerfixed_v2 兼容旧 JWTdynamic_v3 拒绝;绝不用 session_id/command_id 伪造设备。
DeviceId: cmd.DeviceID,
RoomId: cmd.RoomID(), RoomId: cmd.RoomID(),
AnchorId: luckyGiftAnchorID(roomMeta), AnchorId: luckyGiftAnchorID(roomMeta),
GiftId: cmd.GiftID, GiftId: cmd.GiftID,
GiftCount: cmd.GiftCount, GiftCount: cmd.GiftCount,
CoinSpent: targetBilling.Billing.GetCoinSpent(), CoinSpent: targetBilling.Billing.GetCoinSpent(),
// 房间链路统一使用 UTC epoch ms不能把本地时区时间传给幸运礼物 RTP 窗口。 // PaidAtMs 只来自当前目标的 wallet transactionbatch 每个 target 也保留自己的 owner 回执,
PaidAtMs: now.UTC().UnixMilli(), // 不使用聚合回执、Room Cell mutate 时钟或 gift_operation.created_at_ms。
PaidAtMs: paidAtMS,
PoolId: cmd.PoolID, PoolId: cmd.PoolID,
VisibleRegionId: roomMeta.VisibleRegionID, VisibleRegionId: roomMeta.VisibleRegionID,
CountryId: cmd.SenderCountryID, CountryId: cmd.SenderCountryID,
@ -97,7 +118,21 @@ func luckyGiftMetaForTarget(ctx context.Context, meta *roomv1.RequestMeta, cmd c
SenderAvatar: strings.TrimSpace(cmd.SenderDisplayProfile.Avatar), SenderAvatar: strings.TrimSpace(cmd.SenderDisplayProfile.Avatar),
SenderDisplayUserId: strings.TrimSpace(cmd.SenderDisplayProfile.DisplayUserID), SenderDisplayUserId: strings.TrimSpace(cmd.SenderDisplayProfile.DisplayUserID),
SenderPrettyDisplayUserId: strings.TrimSpace(cmd.SenderDisplayProfile.PrettyDisplayUserID), SenderPrettyDisplayUserId: strings.TrimSpace(cmd.SenderDisplayProfile.PrettyDisplayUserID),
// 充值分层和短时加权只消费 wallet 扣费回执里固化的 owner 快照room 不查询充值流水,也不按当前时间重新计算。
Recharge_7DCoins: targetBilling.Billing.GetRechargeSevenDayCoins(),
Recharge_30DCoins: targetBilling.Billing.GetRechargeThirtyDayCoins(),
LastRechargedAtMs: targetBilling.Billing.GetLastRechargedAtMs(),
GiftIncomeCoins: targetBilling.Billing.GetGiftIncomeCoinAmount(),
}, nil
}
func normalizeLuckyGiftStrategyVersion(value string) string {
value = strings.ToLower(strings.TrimSpace(value))
if value == "" {
// 旧 lucky-gift 版本没有返回 strategy_version它发布时只有 fixed_v2故仅该空值兼容成 fixed_v2。
return luckyGiftStrategyFixedV2
} }
return value
} }
func (s *Service) shouldDrawLuckyGift(poolID string, giftTypeCode string) bool { func (s *Service) shouldDrawLuckyGift(poolID string, giftTypeCode string) bool {
@ -140,16 +175,6 @@ func luckyGiftMetaFromRoom(ctx context.Context, meta *roomv1.RequestMeta) *lucky
} }
} }
func luckyGiftDeviceID(cmd command.SendGift, fallbackCommandID string) string {
if value := strings.TrimSpace(cmd.SessionID); value != "" {
return value
}
if value := strings.TrimSpace(fallbackCommandID); value != "" {
return value
}
return cmd.ID()
}
func luckyGiftAnchorID(roomMeta RoomMeta) string { func luckyGiftAnchorID(roomMeta RoomMeta) string {
if roomMeta.OwnerUserID <= 0 { if roomMeta.OwnerUserID <= 0 {
return roomMeta.RoomID return roomMeta.RoomID

View File

@ -22,6 +22,8 @@ type concurrentV2Wallet struct {
calls atomic.Int32 calls atomic.Int32
started chan struct{} started chan struct{}
release chan struct{} release chan struct{}
walletPaidAtMS int64
omitPaidAt bool
} }
func (w *concurrentV2Wallet) DebitGift(ctx context.Context, _ *walletv1.DebitGiftRequest) (*walletv1.DebitGiftResponse, error) { func (w *concurrentV2Wallet) DebitGift(ctx context.Context, _ *walletv1.DebitGiftRequest) (*walletv1.DebitGiftResponse, error) {
@ -35,6 +37,10 @@ func (w *concurrentV2Wallet) DebitGift(ctx context.Context, _ *walletv1.DebitGif
case <-w.release: case <-w.release:
} }
} }
paidAtMS := w.walletPaidAtMS
if paidAtMS <= 0 && !w.omitPaidAt {
paidAtMS = 1_700_000_000_000
}
return &walletv1.DebitGiftResponse{ return &walletv1.DebitGiftResponse{
BillingReceiptId: "receipt-v2-stable", BillingReceiptId: "receipt-v2-stable",
TransactionId: "wallet-tx-v2-stable", TransactionId: "wallet-tx-v2-stable",
@ -45,9 +51,38 @@ func (w *concurrentV2Wallet) DebitGift(ctx context.Context, _ *walletv1.DebitGif
BalanceVersion: 17, BalanceVersion: 17,
GiftIncomeBalanceAfter: 25, GiftIncomeBalanceAfter: 25,
GiftTypeCode: "normal", GiftTypeCode: "normal",
PaidAtMs: paidAtMS,
}, nil }, nil
} }
func TestDynamicLuckyGiftRejectsMissingWalletPaidAtAfterDebit(t *testing.T) {
ctx := appcode.WithContext(context.Background(), appcode.Default)
repository := mysqltest.NewRepository(t)
wallet := &concurrentV2Wallet{omitPaidAt: true}
lucky := &recoverableLuckyClient{}
svc := roomservice.New(roomservice.Config{
NodeID: "node-gift-v2-missing-paid-at", LeaseTTL: 10 * time.Second, RankLimit: 20, SnapshotEveryN: 1,
}, router.NewMemoryDirectory(), repository, wallet, integration.NewNoopRoomEventPublisher(), integration.NewNoopOutboxPublisher(), lucky)
roomID := "room-gift-v2-missing-paid-at"
createRocketRoom(t, ctx, svc, roomID, 351, 9001)
joinRocketRoom(t, ctx, svc, roomID, 352)
request := &roomv1.SendGiftRequest{
Meta: &roomv1.RequestMeta{CommandId: "cmd-v2-missing-paid-at", ActorUserId: 351, RoomId: roomID, AppCode: appcode.Default, DeviceId: "device-v2-missing-paid-at"},
TargetType: "user", TargetUserId: 352, GiftId: "lucky-rose", GiftCount: 1, PoolId: "lucky-pool",
}
if _, err := svc.SendGift(ctx, request); !xerr.IsCode(err, xerr.Unavailable) {
t.Fatalf("dynamic_v3 missing wallet paid_at_ms should fail closed, got %v", err)
}
if lucky.calls.Load() != 0 {
t.Fatalf("dynamic_v3 missing wallet paid_at_ms reached lucky random path %d times", lucky.calls.Load())
}
operation, exists, err := repository.GetGiftOperation(ctx, roomID, request.GetMeta().GetCommandId())
if err != nil || !exists || operation.Status != roomservice.GiftOperationStatusDebited {
t.Fatalf("already debited saga must remain recoverable: exists=%v operation=%+v err=%v", exists, operation, err)
}
}
func TestSendGiftConcurrentCommandReplaysOriginalV2AndKeepsV1Fields(t *testing.T) { func TestSendGiftConcurrentCommandReplaysOriginalV2AndKeepsV1Fields(t *testing.T) {
ctx := appcode.WithContext(context.Background(), appcode.Default) ctx := appcode.WithContext(context.Background(), appcode.Default)
repository := mysqltest.NewRepository(t) repository := mysqltest.NewRepository(t)
@ -120,17 +155,22 @@ func TestSendGiftConcurrentCommandReplaysOriginalV2AndKeepsV1Fields(t *testing.T
type recoverableLuckyClient struct { type recoverableLuckyClient struct {
calls atomic.Int32 calls atomic.Int32
paidAtMS [2]atomic.Int64
} }
func (*recoverableLuckyClient) CheckLuckyGift(context.Context, *luckygiftv1.CheckLuckyGiftRequest) (*luckygiftv1.CheckLuckyGiftResponse, error) { func (*recoverableLuckyClient) CheckLuckyGift(context.Context, *luckygiftv1.CheckLuckyGiftRequest) (*luckygiftv1.CheckLuckyGiftResponse, error) {
return &luckygiftv1.CheckLuckyGiftResponse{Enabled: true, RuleVersion: 1, PoolId: "lucky-pool"}, nil return &luckygiftv1.CheckLuckyGiftResponse{Enabled: true, RuleVersion: 1, PoolId: "lucky-pool", StrategyVersion: "dynamic_v3"}, nil
} }
func (c *recoverableLuckyClient) ExecuteLuckyGiftDraw(_ context.Context, req *luckygiftv1.ExecuteLuckyGiftDrawRequest) (*luckygiftv1.ExecuteLuckyGiftDrawResponse, error) { func (c *recoverableLuckyClient) ExecuteLuckyGiftDraw(_ context.Context, req *luckygiftv1.ExecuteLuckyGiftDrawRequest) (*luckygiftv1.ExecuteLuckyGiftDrawResponse, error) {
if c.calls.Add(1) == 1 { meta := req.GetLuckyGift()
call := c.calls.Add(1)
if call <= 2 {
c.paidAtMS[call-1].Store(meta.GetPaidAtMs())
}
if call == 1 {
return nil, xerr.New(xerr.Unavailable, "temporary lucky outage") return nil, xerr.New(xerr.Unavailable, "temporary lucky outage")
} }
meta := req.GetLuckyGift()
return &luckygiftv1.ExecuteLuckyGiftDrawResponse{Result: &luckygiftv1.LuckyGiftDrawResult{ return &luckygiftv1.ExecuteLuckyGiftDrawResponse{Result: &luckygiftv1.LuckyGiftDrawResult{
DrawId: "draw-recovered", CommandId: meta.GetCommandId(), GiftId: meta.GetGiftId(), PoolId: meta.GetPoolId(), DrawId: "draw-recovered", CommandId: meta.GetCommandId(), GiftId: meta.GetGiftId(), PoolId: meta.GetPoolId(),
MultiplierPpm: 2_000_000, EffectiveRewardCoins: 200, RewardStatus: "granted", CoinBalanceAfter: 1100, MultiplierPpm: 2_000_000, EffectiveRewardCoins: 200, RewardStatus: "granted", CoinBalanceAfter: 1100,
@ -153,13 +193,16 @@ func (c *recoverableLuckyClient) BatchExecuteLuckyGiftDraw(ctx context.Context,
func TestGiftOperationWorkerFinishesDebitedRequestWithoutClientRetry(t *testing.T) { func TestGiftOperationWorkerFinishesDebitedRequestWithoutClientRetry(t *testing.T) {
ctx := appcode.WithContext(context.Background(), appcode.Default) ctx := appcode.WithContext(context.Background(), appcode.Default)
repository := mysqltest.NewRepository(t) repository := mysqltest.NewRepository(t)
wallet := &concurrentV2Wallet{} walletPaidAtMS := time.Date(2026, 7, 12, 23, 59, 59, 700_000_000, time.UTC).UnixMilli()
wallet := &concurrentV2Wallet{walletPaidAtMS: walletPaidAtMS}
lucky := &recoverableLuckyClient{} lucky := &recoverableLuckyClient{}
clock := &fixedRoomRocketClock{now: time.Date(2026, 7, 12, 23, 59, 59, 800_000_000, time.UTC)}
svc := roomservice.New(roomservice.Config{ svc := roomservice.New(roomservice.Config{
NodeID: "node-gift-v2-recovery", NodeID: "node-gift-v2-recovery",
LeaseTTL: 10 * time.Second, LeaseTTL: 48 * time.Hour,
RankLimit: 20, RankLimit: 20,
SnapshotEveryN: 1, SnapshotEveryN: 1,
Clock: clock,
}, router.NewMemoryDirectory(), repository, wallet, integration.NewNoopRoomEventPublisher(), integration.NewNoopOutboxPublisher(), lucky) }, router.NewMemoryDirectory(), repository, wallet, integration.NewNoopRoomEventPublisher(), integration.NewNoopOutboxPublisher(), lucky)
roomID := "room-gift-v2-recovery" roomID := "room-gift-v2-recovery"
@ -167,7 +210,7 @@ func TestGiftOperationWorkerFinishesDebitedRequestWithoutClientRetry(t *testing.
createRocketRoom(t, ctx, svc, roomID, senderID, 9001) createRocketRoom(t, ctx, svc, roomID, senderID, 9001)
joinRocketRoom(t, ctx, svc, roomID, targetID) joinRocketRoom(t, ctx, svc, roomID, targetID)
req := &roomv1.SendGiftRequest{ req := &roomv1.SendGiftRequest{
Meta: &roomv1.RequestMeta{RequestId: "req-v2-recovery", CommandId: "cmd-v2-recovery", ActorUserId: senderID, RoomId: roomID, AppCode: appcode.Default}, Meta: &roomv1.RequestMeta{RequestId: "req-v2-recovery", CommandId: "cmd-v2-recovery", ActorUserId: senderID, RoomId: roomID, AppCode: appcode.Default, DeviceId: "device-v2-recovery"},
TargetType: "user", TargetUserId: targetID, GiftId: "lucky-rose", GiftCount: 1, PoolId: "lucky-pool", TargetType: "user", TargetUserId: targetID, GiftId: "lucky-rose", GiftCount: 1, PoolId: "lucky-pool",
} }
if _, err := svc.SendGift(ctx, req); !xerr.IsCode(err, xerr.Unavailable) { if _, err := svc.SendGift(ctx, req); !xerr.IsCode(err, xerr.Unavailable) {
@ -177,6 +220,12 @@ func TestGiftOperationWorkerFinishesDebitedRequestWithoutClientRetry(t *testing.
if err != nil || !exists || operation.Status != roomservice.GiftOperationStatusDebited { if err != nil || !exists || operation.Status != roomservice.GiftOperationStatusDebited {
t.Fatalf("debited saga was not persisted: exists=%v operation=%+v err=%v", exists, operation, err) t.Fatalf("debited saga was not persisted: exists=%v operation=%+v err=%v", exists, operation, err)
} }
// 恢复跨过五分钟加权窗和 UTC 切日后lucky-service 仍必须收到 wallet 首次交易时间;
// saga created_at 与 wallet paid_at 即使只差 100ms 也不是同一个 owner 事实,不能相互替代。
clock.now = clock.now.Add(6 * time.Minute)
if clock.Now().Sub(time.UnixMilli(operation.CreatedAtMS)) <= 5*time.Minute || clock.Now().UTC().Day() == time.UnixMilli(operation.CreatedAtMS).UTC().Day() {
t.Fatalf("test clock did not cross both recovery boundaries: created=%s recovery=%s", time.UnixMilli(operation.CreatedAtMS).UTC(), clock.Now())
}
if _, err := svc.CloseRoom(ctx, &roomv1.CloseRoomRequest{ if _, err := svc.CloseRoom(ctx, &roomv1.CloseRoomRequest{
Meta: &roomv1.RequestMeta{CommandId: "close-during-gift-recovery", ActorUserId: senderID, RoomId: roomID, AppCode: appcode.Default}, Meta: &roomv1.RequestMeta{CommandId: "close-during-gift-recovery", ActorUserId: senderID, RoomId: roomID, AppCode: appcode.Default},
}); !xerr.IsCode(err, xerr.Conflict) { }); !xerr.IsCode(err, xerr.Conflict) {
@ -203,6 +252,12 @@ func TestGiftOperationWorkerFinishesDebitedRequestWithoutClientRetry(t *testing.
} }
time.Sleep(20 * time.Millisecond) time.Sleep(20 * time.Millisecond)
} }
if operation.CreatedAtMS == walletPaidAtMS {
t.Fatalf("test must distinguish room saga time from wallet transaction time: %d", walletPaidAtMS)
}
if firstPaidAt, recoveredPaidAt := lucky.paidAtMS[0].Load(), lucky.paidAtMS[1].Load(); firstPaidAt != walletPaidAtMS || recoveredPaidAt != walletPaidAtMS {
t.Fatalf("lucky paid_at_ms drifted from wallet fact across recovery: wallet=%d operation=%d first=%d recovered=%d", walletPaidAtMS, operation.CreatedAtMS, firstPaidAt, recoveredPaidAt)
}
replayed, err := svc.SendGift(ctx, req) replayed, err := svc.SendGift(ctx, req)
if err != nil { if err != nil {

View File

@ -214,6 +214,7 @@ func baseFromMeta(meta *roomv1.RequestMeta) command.Base {
Room: meta.GetRoomId(), Room: meta.GetRoomId(),
GatewayNodeID: meta.GetGatewayNodeId(), GatewayNodeID: meta.GetGatewayNodeId(),
SessionID: meta.GetSessionId(), SessionID: meta.GetSessionId(),
DeviceID: meta.GetDeviceId(),
SentAtMS: meta.GetSentAtMs(), SentAtMS: meta.GetSentAtMs(),
} }
} }
@ -256,6 +257,7 @@ func requestMetaWithRoomID(meta *roomv1.RequestMeta, roomID string) *roomv1.Requ
RoomId: roomID, RoomId: roomID,
GatewayNodeId: meta.GetGatewayNodeId(), GatewayNodeId: meta.GetGatewayNodeId(),
SessionId: meta.GetSessionId(), SessionId: meta.GetSessionId(),
DeviceId: meta.GetDeviceId(),
SentAtMs: meta.GetSentAtMs(), SentAtMs: meta.GetSentAtMs(),
AppCode: meta.GetAppCode(), AppCode: meta.GetAppCode(),
} }

View File

@ -10,6 +10,7 @@ import (
roomv1 "hyapp.local/api/proto/room/v1" roomv1 "hyapp.local/api/proto/room/v1"
walletv1 "hyapp.local/api/proto/wallet/v1" walletv1 "hyapp.local/api/proto/wallet/v1"
"hyapp/pkg/appcode" "hyapp/pkg/appcode"
"hyapp/pkg/xerr"
"hyapp/services/room-service/internal/integration" "hyapp/services/room-service/internal/integration"
roomservice "hyapp/services/room-service/internal/room/service" roomservice "hyapp/services/room-service/internal/room/service"
"hyapp/services/room-service/internal/router" "hyapp/services/room-service/internal/router"
@ -21,6 +22,8 @@ type luckyGiftTestClient struct {
draws []*luckygiftv1.ExecuteLuckyGiftDrawRequest draws []*luckygiftv1.ExecuteLuckyGiftDrawRequest
batchDraws []*luckygiftv1.BatchExecuteLuckyGiftDrawRequest batchDraws []*luckygiftv1.BatchExecuteLuckyGiftDrawRequest
drawResults []*luckygiftv1.LuckyGiftDrawResult drawResults []*luckygiftv1.LuckyGiftDrawResult
// checkStrategy 让 room 测试显式区分 fixed_v2 滚动兼容与 dynamic_v3 强设备事实。
checkStrategy string
} }
func (c *luckyGiftTestClient) CheckLuckyGift(_ context.Context, req *luckygiftv1.CheckLuckyGiftRequest) (*luckygiftv1.CheckLuckyGiftResponse, error) { func (c *luckyGiftTestClient) CheckLuckyGift(_ context.Context, req *luckygiftv1.CheckLuckyGiftRequest) (*luckygiftv1.CheckLuckyGiftResponse, error) {
@ -32,6 +35,7 @@ func (c *luckyGiftTestClient) CheckLuckyGift(_ context.Context, req *luckygiftv1
PoolId: req.GetPoolId(), PoolId: req.GetPoolId(),
RuleVersion: 12, RuleVersion: 12,
ExperiencePool: "novice", ExperiencePool: "novice",
StrategyVersion: c.checkStrategy,
}, nil }, nil
} }
@ -116,13 +120,22 @@ func (c *luckyGiftTestClient) BatchExecuteLuckyGiftDraw(_ context.Context, req *
func TestSendGiftReturnsLuckyGiftDrawResult(t *testing.T) { func TestSendGiftReturnsLuckyGiftDrawResult(t *testing.T) {
ctx := context.Background() ctx := context.Background()
repository := mysqltest.NewRepository(t) repository := mysqltest.NewRepository(t)
wallet := &rocketTestWallet{debits: []*walletv1.DebitGiftResponse{{ wallet := &rocketTestWallet{debits: []*walletv1.DebitGiftResponse{
{
BillingReceiptId: "receipt-lucky", BillingReceiptId: "receipt-lucky",
CoinSpent: 100, CoinSpent: 100,
GiftPointAdded: 100, GiftPointAdded: 100,
HeatValue: 100, HeatValue: 100,
GiftTypeCode: "super_lucky", GiftTypeCode: "super_lucky",
}}} },
{
BillingReceiptId: "receipt-lucky-legacy-token",
CoinSpent: 100,
GiftPointAdded: 100,
HeatValue: 100,
GiftTypeCode: "super_lucky",
},
}}
luckyGift := &luckyGiftTestClient{} luckyGift := &luckyGiftTestClient{}
svc := roomservice.New(roomservice.Config{ svc := roomservice.New(roomservice.Config{
NodeID: "node-lucky-test", NodeID: "node-lucky-test",
@ -143,7 +156,8 @@ func TestSendGiftReturnsLuckyGiftDrawResult(t *testing.T) {
CommandId: "cmd-lucky", CommandId: "cmd-lucky",
ActorUserId: ownerID, ActorUserId: ownerID,
RoomId: roomID, RoomId: roomID,
SessionId: "device-session-1", SessionId: "sess-must-not-be-device-1",
DeviceId: "device-auth-session-1",
AppCode: appcode.Default, AppCode: appcode.Default,
SentAtMs: time.Date(2026, 5, 20, 12, 0, 0, 0, time.UTC).UnixMilli(), SentAtMs: time.Date(2026, 5, 20, 12, 0, 0, 0, time.UTC).UnixMilli(),
}, },
@ -178,7 +192,7 @@ func TestSendGiftReturnsLuckyGiftDrawResult(t *testing.T) {
drawMeta := luckyGift.draws[0].GetLuckyGift() drawMeta := luckyGift.draws[0].GetLuckyGift()
if drawMeta.GetCommandId() != "cmd-lucky" || if drawMeta.GetCommandId() != "cmd-lucky" ||
drawMeta.GetCoinSpent() != 100 || drawMeta.GetCoinSpent() != 100 ||
drawMeta.GetDeviceId() != "device-session-1" || drawMeta.GetDeviceId() != "device-auth-session-1" ||
drawMeta.GetAnchorId() != "101" || drawMeta.GetAnchorId() != "101" ||
drawMeta.GetVisibleRegionId() != 9001 || drawMeta.GetVisibleRegionId() != 9001 ||
drawMeta.GetSenderName() != "lucky sender" || drawMeta.GetSenderName() != "lucky sender" ||
@ -187,6 +201,40 @@ func TestSendGiftReturnsLuckyGiftDrawResult(t *testing.T) {
drawMeta.GetSenderPrettyDisplayUserId() != "HY-123456" { drawMeta.GetSenderPrettyDisplayUserId() != "HY-123456" {
t.Fatalf("lucky draw meta mismatch: %+v", drawMeta) t.Fatalf("lucky draw meta mismatch: %+v", drawMeta)
} }
// 旧 access JWT 没有 device_id 时room 必须原样传空以保留 fixed_v2 兼容性;
// 不能因为需要一个非空风控键就用 session_id 或 command_id 代替设备。
if _, err := svc.SendGift(ctx, &roomv1.SendGiftRequest{
Meta: &roomv1.RequestMeta{
RequestId: "req-lucky-legacy-token", CommandId: "cmd-lucky-legacy-token",
ActorUserId: ownerID, RoomId: roomID, SessionId: "sess-legacy-not-device",
AppCode: appcode.Default, SentAtMs: time.Date(2026, 5, 20, 12, 1, 0, 0, time.UTC).UnixMilli(),
},
TargetType: "user", TargetUserId: viewerID, GiftId: "rose", GiftCount: 1, PoolId: "super_lucky",
}); err != nil {
t.Fatalf("legacy-token fixed-compatible lucky send failed: %v", err)
}
legacyDraw := luckyGift.draws[len(luckyGift.draws)-1].GetLuckyGift()
if legacyDraw.GetDeviceId() != "" {
t.Fatalf("legacy-token lucky device_id=%q, want empty instead of session/command fallback", legacyDraw.GetDeviceId())
}
luckyGift.checkStrategy = "dynamic_v3"
debitsBeforeDynamicReject := len(wallet.debitRequests)
_, err = svc.SendGift(ctx, &roomv1.SendGiftRequest{
Meta: &roomv1.RequestMeta{
RequestId: "req-lucky-dynamic-missing-device", CommandId: "cmd-lucky-dynamic-missing-device",
ActorUserId: ownerID, RoomId: roomID, SessionId: "sess-still-not-a-device",
AppCode: appcode.Default, SentAtMs: time.Date(2026, 5, 20, 12, 2, 0, 0, time.UTC).UnixMilli(),
},
TargetType: "user", TargetUserId: viewerID, GiftId: "rose", GiftCount: 1, PoolId: "super_lucky",
})
if !xerr.IsCode(err, xerr.InvalidArgument) {
t.Fatalf("dynamic_v3 missing signed device_id should fail before debit, got %v", err)
}
if len(wallet.debitRequests) != debitsBeforeDynamicReject {
t.Fatalf("dynamic_v3 missing device reached wallet: before=%d after=%d", debitsBeforeDynamicReject, len(wallet.debitRequests))
}
} }
func TestSendGiftReturnsLuckyGiftDrawResultsForMultipleTargets(t *testing.T) { func TestSendGiftReturnsLuckyGiftDrawResultsForMultipleTargets(t *testing.T) {
@ -221,7 +269,8 @@ func TestSendGiftReturnsLuckyGiftDrawResultsForMultipleTargets(t *testing.T) {
CommandId: "cmd-lucky-multi", CommandId: "cmd-lucky-multi",
ActorUserId: ownerID, ActorUserId: ownerID,
RoomId: roomID, RoomId: roomID,
SessionId: "device-session-multi", SessionId: "sess-must-not-be-device-multi",
DeviceId: "device-auth-session-multi",
AppCode: appcode.Default, AppCode: appcode.Default,
SentAtMs: time.Date(2026, 5, 20, 12, 0, 0, 0, time.UTC).UnixMilli(), SentAtMs: time.Date(2026, 5, 20, 12, 0, 0, 0, time.UTC).UnixMilli(),
}, },
@ -251,6 +300,9 @@ func TestSendGiftReturnsLuckyGiftDrawResultsForMultipleTargets(t *testing.T) {
if secondDraw.GetCommandId() != "cmd-lucky-multi:target:303" || secondDraw.GetTargetUserId() != secondTargetID || secondDraw.GetCoinSpent() != 200 { if secondDraw.GetCommandId() != "cmd-lucky-multi:target:303" || secondDraw.GetTargetUserId() != secondTargetID || secondDraw.GetCoinSpent() != 200 {
t.Fatalf("second target lucky draw mismatch: %+v", secondDraw) t.Fatalf("second target lucky draw mismatch: %+v", secondDraw)
} }
if firstDraw.GetDeviceId() != "device-auth-session-multi" || secondDraw.GetDeviceId() != "device-auth-session-multi" {
t.Fatalf("multi-target draws must share the signed JWT device scope: first=%q second=%q", firstDraw.GetDeviceId(), secondDraw.GetDeviceId())
}
if resp.GetLuckyGift().GetCommandId() != "cmd-lucky-multi" || if resp.GetLuckyGift().GetCommandId() != "cmd-lucky-multi" ||
resp.GetLuckyGift().GetTargetUserId() != 0 || resp.GetLuckyGift().GetTargetUserId() != 0 ||
resp.GetLuckyGift().GetSelectedTierId() != "batch" || resp.GetLuckyGift().GetSelectedTierId() != "batch" ||

View File

@ -69,7 +69,7 @@ func (s *Service) LoginPassword(ctx context.Context, displayUserID string, passw
s.audit(ctx, meta, user.UserID, loginPassword, "", resultSuccess, "") s.audit(ctx, meta, user.UserID, loginPassword, "", resultSuccess, "")
// 登录成功即刷新设备档案;写入是 best-effort不影响 token 返回。 // 登录成功即刷新设备档案;写入是 best-effort不影响 token 返回。
s.recordUserDevice(ctx, meta, user.UserID, deviceID) s.recordUserDevice(ctx, meta, user.UserID, deviceID)
token, err := s.issueToken(user, session.SessionID, refreshToken, session.ExpiresAtMs) token, err := s.issueToken(user, session.SessionID, session.DeviceID, refreshToken, session.ExpiresAtMs)
if err != nil { if err != nil {
return authdomain.Token{}, err return authdomain.Token{}, err
} }

View File

@ -123,7 +123,7 @@ func (s *Service) QuickCreateAccount(ctx context.Context, password string, regis
s.audit(ctx, meta, user.UserID, loginPassword, "", resultSuccess, "") s.audit(ctx, meta, user.UserID, loginPassword, "", resultSuccess, "")
// 快捷账号也是可登录主体,设备档案与普通注册保持一致;机器人分支在上方已提前返回。 // 快捷账号也是可登录主体,设备档案与普通注册保持一致;机器人分支在上方已提前返回。
s.recordUserDevice(ctx, deviceMetaWithRegistration(meta, registration), user.UserID, registration.DeviceID) s.recordUserDevice(ctx, deviceMetaWithRegistration(meta, registration), user.UserID, registration.DeviceID)
token, tokenErr := s.issueToken(user, session.SessionID, refreshToken, session.ExpiresAtMs) token, tokenErr := s.issueToken(user, session.SessionID, session.DeviceID, refreshToken, session.ExpiresAtMs)
if tokenErr != nil { if tokenErr != nil {
return authdomain.Token{}, tokenErr return authdomain.Token{}, tokenErr
} }

View File

@ -43,6 +43,23 @@ func accessTokenExpUnix(t *testing.T, accessToken string) int64 {
return exp.Unix() return exp.Unix()
} }
func accessTokenStringClaim(t *testing.T, accessToken string, name string) string {
t.Helper()
parser := jwt.NewParser(jwt.WithValidMethods([]string{"HS256"}), jwt.WithoutClaimsValidation())
parsed, err := parser.Parse(accessToken, func(token *jwt.Token) (any, error) {
return []byte("test-secret"), nil
})
if err != nil || !parsed.Valid {
t.Fatalf("parse access token claim %s failed: token=%q err=%v", name, accessToken, err)
}
claims, ok := parsed.Claims.(jwt.MapClaims)
if !ok {
t.Fatalf("access token claim set has unexpected type %T", parsed.Claims)
}
value, _ := claims[name].(string)
return value
}
type memoryDecisionCache struct { type memoryDecisionCache struct {
mu sync.Mutex mu sync.Mutex
ip map[string]authservice.IPDecision ip map[string]authservice.IPDecision
@ -363,6 +380,10 @@ func TestThirdPartyUserSetsPasswordThenLogsIn(t *testing.T) {
if got, want := accessTokenExpUnix(t, thirdToken.AccessToken), now.Unix()+testAccessTokenTTLSec; got != want { if got, want := accessTokenExpUnix(t, thirdToken.AccessToken), now.Unix()+testAccessTokenTTLSec; got != want {
t.Fatalf("third-party access token exp mismatch: got=%d want=%d", got, want) t.Fatalf("third-party access token exp mismatch: got=%d want=%d", got, want)
} }
if got := accessTokenStringClaim(t, thirdToken.AccessToken, "device_id"); got != "dev-ios" {
// 登录签发必须使用 auth_session 的设备绑定,不能让下游拿 sid 代替设备风控 scope。
t.Fatalf("third-party access token device_id=%q, want dev-ios", got)
}
if thirdToken.ProfileCompleted || thirdToken.OnboardingStatus != string(userdomain.OnboardingStatusProfileRequired) { if thirdToken.ProfileCompleted || thirdToken.OnboardingStatus != string(userdomain.OnboardingStatusProfileRequired) {
t.Fatalf("new third-party user must require onboarding: %+v", thirdToken) t.Fatalf("new third-party user must require onboarding: %+v", thirdToken)
} }
@ -398,6 +419,9 @@ func TestThirdPartyUserSetsPasswordThenLogsIn(t *testing.T) {
if got, want := accessTokenExpUnix(t, loginToken.AccessToken), now.Unix()+testAccessTokenTTLSec; got != want { if got, want := accessTokenExpUnix(t, loginToken.AccessToken), now.Unix()+testAccessTokenTTLSec; got != want {
t.Fatalf("password login access token exp mismatch: got=%d want=%d", got, want) t.Fatalf("password login access token exp mismatch: got=%d want=%d", got, want)
} }
if got := accessTokenStringClaim(t, loginToken.AccessToken, "device_id"); got != "ios" {
t.Fatalf("password access token device_id=%q, want ios", got)
}
var auditedAppVersion string var auditedAppVersion string
var auditedBuildNumber string var auditedBuildNumber string
if err := repository.RawDB().QueryRowContext(ctx, ` if err := repository.RawDB().QueryRowContext(ctx, `
@ -435,6 +459,9 @@ func TestThirdPartyUserSetsPasswordThenLogsIn(t *testing.T) {
if got, want := accessTokenExpUnix(t, refreshToken.AccessToken), now.Unix()+testAccessTokenTTLSec; got != want { if got, want := accessTokenExpUnix(t, refreshToken.AccessToken), now.Unix()+testAccessTokenTTLSec; got != want {
t.Fatalf("refresh access token exp mismatch: got=%d want=%d", got, want) t.Fatalf("refresh access token exp mismatch: got=%d want=%d", got, want)
} }
if got := accessTokenStringClaim(t, refreshToken.AccessToken, "device_id"); got != "ios" {
t.Fatalf("refreshed access token device_id=%q, want ios", got)
}
if got := cache.revoked["lalu:"+loginToken.SessionID]; got != "REFRESH_ROTATED" { if got := cache.revoked["lalu:"+loginToken.SessionID]; got != "REFRESH_ROTATED" {
t.Fatalf("refresh must denylist rotated access session, got %q", got) t.Fatalf("refresh must denylist rotated access session, got %q", got)
} }
@ -1961,6 +1988,10 @@ func TestIssueAccessTokenForSessionCarriesProfileRequiredOnboardingStatusWithout
if reissued.SessionID != token.SessionID || reissued.RefreshToken != "" { if reissued.SessionID != token.SessionID || reissued.RefreshToken != "" {
t.Fatalf("access resign must reuse session and not mint refresh token: %+v", reissued) t.Fatalf("access resign must reuse session and not mint refresh token: %+v", reissued)
} }
if got := accessTokenStringClaim(t, reissued.AccessToken, "device_id"); got != "dev-ios" {
// access resign 不接受客户端 device_id只能从已校验的 active session 回读原绑定值。
t.Fatalf("reissued access token device_id=%q, want dev-ios", got)
}
} }
func TestIssueAccessTokenForSessionCapsTTLAtSessionExpiry(t *testing.T) { func TestIssueAccessTokenForSessionCapsTTLAtSessionExpiry(t *testing.T) {

View File

@ -248,7 +248,7 @@ func (s *Service) loginExistingThirdParty(ctx context.Context, identity authdoma
// 老用户换机三方登录也要立即出现在设备 tabheader 缺失时回退 body 设备字段。 // 老用户换机三方登录也要立即出现在设备 tabheader 缺失时回退 body 设备字段。
s.recordUserDevice(ctx, deviceMetaWithRegistration(meta, registration), user.UserID, registration.DeviceID) s.recordUserDevice(ctx, deviceMetaWithRegistration(meta, registration), user.UserID, registration.DeviceID)
// isNewUser=false 表示本次只创建 session没有创建用户。 // isNewUser=false 表示本次只创建 session没有创建用户。
token, err := s.issueToken(user, session.SessionID, refreshToken, session.ExpiresAtMs) token, err := s.issueToken(user, session.SessionID, session.DeviceID, refreshToken, session.ExpiresAtMs)
if err != nil { if err != nil {
return authdomain.Token{}, false, err return authdomain.Token{}, false, err
} }
@ -300,7 +300,7 @@ func (s *Service) registerExistingThirdParty(ctx context.Context, identity authd
s.audit(ctx, meta, updated.UserID, loginThird, identity.Provider, resultSuccess, "") s.audit(ctx, meta, updated.UserID, loginThird, identity.Provider, resultSuccess, "")
// 补齐资料完成注册同样属于认证成功,设备档案与 session 创建保持同步。 // 补齐资料完成注册同样属于认证成功,设备档案与 session 创建保持同步。
s.recordUserDevice(ctx, deviceMetaWithRegistration(meta, registration), updated.UserID, registration.DeviceID) s.recordUserDevice(ctx, deviceMetaWithRegistration(meta, registration), updated.UserID, registration.DeviceID)
token, err := s.issueToken(updated, session.SessionID, refreshToken, session.ExpiresAtMs) token, err := s.issueToken(updated, session.SessionID, session.DeviceID, refreshToken, session.ExpiresAtMs)
if err != nil { if err != nil {
return authdomain.Token{}, false, err return authdomain.Token{}, false, err
} }
@ -360,7 +360,7 @@ func (s *Service) createThirdPartyUser(ctx context.Context, provider string, pro
s.audit(ctx, meta, user.UserID, loginThird, provider, resultSuccess, "") s.audit(ctx, meta, user.UserID, loginThird, provider, resultSuccess, "")
// 新注册用户的首个设备档案与注册事务同请求写入,后台无需等下次登录。 // 新注册用户的首个设备档案与注册事务同请求写入,后台无需等下次登录。
s.recordUserDevice(ctx, deviceMetaWithRegistration(meta, registration), user.UserID, registration.DeviceID) s.recordUserDevice(ctx, deviceMetaWithRegistration(meta, registration), user.UserID, registration.DeviceID)
token, tokenErr := s.issueToken(user, session.SessionID, refreshToken, session.ExpiresAtMs) token, tokenErr := s.issueToken(user, session.SessionID, session.DeviceID, refreshToken, session.ExpiresAtMs)
if tokenErr != nil { if tokenErr != nil {
return authdomain.Token{}, false, tokenErr return authdomain.Token{}, false, tokenErr
} }
@ -444,7 +444,7 @@ func (s *Service) createRegisteredThirdPartyUser(ctx context.Context, provider s
s.audit(ctx, meta, user.UserID, loginThird, provider, resultSuccess, "") s.audit(ctx, meta, user.UserID, loginThird, provider, resultSuccess, "")
// 注册专用入口创建的 completed 用户同样落首个设备档案。 // 注册专用入口创建的 completed 用户同样落首个设备档案。
s.recordUserDevice(ctx, deviceMetaWithRegistration(meta, registration), user.UserID, registration.DeviceID) s.recordUserDevice(ctx, deviceMetaWithRegistration(meta, registration), user.UserID, registration.DeviceID)
token, tokenErr := s.issueToken(user, session.SessionID, refreshToken, session.ExpiresAtMs) token, tokenErr := s.issueToken(user, session.SessionID, session.DeviceID, refreshToken, session.ExpiresAtMs)
if tokenErr != nil { if tokenErr != nil {
return authdomain.Token{}, false, tokenErr return authdomain.Token{}, false, tokenErr
} }

View File

@ -86,7 +86,7 @@ func (s *Service) RefreshToken(ctx context.Context, refreshToken string, deviceI
if createErr != nil { if createErr != nil {
return authdomain.Token{}, createErr return authdomain.Token{}, createErr
} }
candidateToken, issueErr := s.issueToken(user, newSession.SessionID, newRefreshToken, newSession.ExpiresAtMs) candidateToken, issueErr := s.issueToken(user, newSession.SessionID, newSession.DeviceID, newRefreshToken, newSession.ExpiresAtMs)
if issueErr != nil { if issueErr != nil {
return authdomain.Token{}, issueErr return authdomain.Token{}, issueErr
} }
@ -186,7 +186,7 @@ func (s *Service) IssueAccessTokenForSession(ctx context.Context, userID int64,
} }
s.audit(ctx, meta, userID, loginAccessResign, "", resultSuccess, "") s.audit(ctx, meta, userID, loginAccessResign, "", resultSuccess, "")
return s.issueToken(user, session.SessionID, "", session.ExpiresAtMs) return s.issueToken(user, session.SessionID, session.DeviceID, "", session.ExpiresAtMs)
} }
// AdminIssueUserAccessToken 供后台按用户当前最新 active session 重签 access token。 // AdminIssueUserAccessToken 供后台按用户当前最新 active session 重签 access token。
@ -221,7 +221,7 @@ func (s *Service) AdminIssueUserAccessToken(ctx context.Context, userID int64, m
} }
// refreshToken 传空:后台没有轮换会话的理由,响应中也绝不携带 refresh 原文。 // refreshToken 传空:后台没有轮换会话的理由,响应中也绝不携带 refresh 原文。
token, err := s.issueToken(user, session.SessionID, "", session.ExpiresAtMs) token, err := s.issueToken(user, session.SessionID, session.DeviceID, "", session.ExpiresAtMs)
if err != nil { if err != nil {
return authdomain.Token{}, authdomain.Session{}, err return authdomain.Token{}, authdomain.Session{}, err
} }
@ -413,7 +413,7 @@ func refreshOutcomeAAD(appCode string, parentSessionID string, childSessionID st
return []byte(strings.Join([]string{refreshReplayCipherVersion, appcode.Normalize(appCode), parentSessionID, childSessionID, refreshRequestID}, "\x00")) return []byte(strings.Join([]string{refreshReplayCipherVersion, appcode.Normalize(appCode), parentSessionID, childSessionID, refreshRequestID}, "\x00"))
} }
func (s *Service) issueToken(user userdomain.User, sessionID string, refreshToken string, sessionExpiresAtMs int64) (authdomain.Token, error) { func (s *Service) issueToken(user userdomain.User, sessionID string, deviceID string, refreshToken string, sessionExpiresAtMs int64) (authdomain.Token, error) {
now := s.now() now := s.now()
expiresInSec := s.cfg.AccessTokenTTLSec expiresInSec := s.cfg.AccessTokenTTLSec
expiresAt := now.Add(time.Duration(expiresInSec) * time.Second) expiresAt := now.Add(time.Duration(expiresInSec) * time.Second)
@ -434,6 +434,8 @@ func (s *Service) issueToken(user userdomain.User, sessionID string, refreshToke
onboardingStatus := tokenOnboardingStatus(user) onboardingStatus := tokenOnboardingStatus(user)
// JWT 只携带内部 user_id客户端展示和人工输入统一使用 display_user_id。 // JWT 只携带内部 user_id客户端展示和人工输入统一使用 display_user_id。
// user_id 必须写成字符串:雪花 ID 超过 JS/JSON number 的安全整数范围,写成 number 会在 gateway 解析时丢精度。 // user_id 必须写成字符串:雪花 ID 超过 JS/JSON number 的安全整数范围,写成 number 会在 gateway 解析时丢精度。
// device_id 只能来自已落库 auth_session 的绑定值;心跳/资料完成重签也必须从同一 session 回读,
// 不允许 gateway 用 sid 或业务 command_id 伪造设备风控作用域。
claims := jwt.MapClaims{ claims := jwt.MapClaims{
"iss": s.cfg.Issuer, "iss": s.cfg.Issuer,
"app_code": appcode.Normalize(user.AppCode), "app_code": appcode.Normalize(user.AppCode),
@ -446,6 +448,7 @@ func (s *Service) issueToken(user userdomain.User, sessionID string, refreshToke
"profile_completed": user.ProfileCompleted, "profile_completed": user.ProfileCompleted,
"onboarding_status": onboardingStatus, "onboarding_status": onboardingStatus,
"sid": sessionID, "sid": sessionID,
"device_id": strings.TrimSpace(deviceID),
"typ": "access", "typ": "access",
"iat": now.Unix(), "iat": now.Unix(),
"exp": expiresAt.Unix(), "exp": expiresAt.Unix(),

View File

@ -26,3 +26,11 @@ func (s *Service) ResolveApp(ctx context.Context, code string, packageName strin
return s.appRegistryRepository.ResolveApp(ctx, code, packageName, platform) return s.appRegistryRepository.ResolveApp(ctx, code, packageName, platform)
} }
// ListApps 返回注册表中所有启用租户,供后台任务动态覆盖新 App。
func (s *Service) ListApps(ctx context.Context) ([]userdomain.App, error) {
if s.appRegistryRepository == nil {
return nil, xerr.New(xerr.Unavailable, "app repository is not configured")
}
return s.appRegistryRepository.ListApps(ctx)
}

View File

@ -76,6 +76,7 @@ type UserRepository interface {
// AppRegistryRepository 负责把客户端包名解析成内部 app_code。 // AppRegistryRepository 负责把客户端包名解析成内部 app_code。
type AppRegistryRepository interface { type AppRegistryRepository interface {
ResolveApp(ctx context.Context, appCode string, packageName string, platform string) (userdomain.App, error) ResolveApp(ctx context.Context, appCode string, packageName string, platform string) (userdomain.App, error)
ListApps(ctx context.Context) ([]userdomain.App, error)
} }
// CountryRegionRepository 负责把用户选择的 country_code 解析成国家主数据和当前 active 区域。 // CountryRegionRepository 负责把用户选择的 country_code 解析成国家主数据和当前 active 区域。

View File

@ -77,3 +77,28 @@ func (r *Repository) ResolveApp(ctx context.Context, code string, packageName st
} }
return app, nil return app, nil
} }
// ListApps 只返回 active 注册表事实;排序稳定,便于 cron 生成独立租户租约。
func (r *Repository) ListApps(ctx context.Context) ([]userdomain.App, error) {
if r == nil || r.db == nil {
return nil, xerr.New(xerr.Unavailable, "app repository is not configured")
}
rows, err := r.db.QueryContext(ctx, `
SELECT app_id, app_code, app_name, package_name, platform, status, created_at_ms, updated_at_ms
FROM apps
WHERE status = ?
ORDER BY app_code ASC`, userdomain.AppStatusActive)
if err != nil {
return nil, err
}
defer rows.Close()
apps := make([]userdomain.App, 0)
for rows.Next() {
var app userdomain.App
if err := rows.Scan(&app.AppID, &app.AppCode, &app.AppName, &app.PackageName, &app.Platform, &app.Status, &app.CreatedAtMs, &app.UpdatedAtMs); err != nil {
return nil, err
}
apps = append(apps, app)
}
return apps, rows.Err()
}

View File

@ -52,3 +52,23 @@ func TestResolveAppRejectsUnsupportedPackage(t *testing.T) {
t.Fatalf("expected invalid argument for unsupported package, got %v", err) t.Fatalf("expected invalid argument for unsupported package, got %v", err)
} }
} }
func TestListAppsReturnsOnlyActiveAppsInStableOrder(t *testing.T) {
repo := mysqltest.NewRepository(t).Repository.AppRepository()
apps, err := repo.ListApps(context.Background())
if err != nil {
t.Fatalf("ListApps failed: %v", err)
}
if len(apps) == 0 {
t.Fatal("ListApps must return seeded active apps")
}
for index, app := range apps {
if app.Status != "active" {
t.Fatalf("inactive app leaked into registry: %+v", app)
}
if index > 0 && apps[index-1].AppCode > app.AppCode {
t.Fatalf("apps must be sorted by app_code: %+v", apps)
}
}
}

View File

@ -103,6 +103,19 @@ func (s *Server) ResolveApp(ctx context.Context, req *userv1.ResolveAppRequest)
return &userv1.ResolveAppResponse{App: toProtoApp(app)}, nil return &userv1.ResolveAppResponse{App: toProtoApp(app)}, nil
} }
// ListApps 给内部 scheduler 提供启用租户目录,避免每个后台任务在 YAML 重复维护 app_code。
func (s *Server) ListApps(ctx context.Context, _ *userv1.ListAppsRequest) (*userv1.ListAppsResponse, error) {
apps, err := s.userSvc.ListApps(ctx)
if err != nil {
return nil, xerr.ToGRPCError(err)
}
items := make([]*userv1.App, 0, len(apps))
for _, app := range apps {
items = append(items, toProtoApp(app))
}
return &userv1.ListAppsResponse{Apps: items}, nil
}
// LoginPassword 统一把短号不存在、未设置密码和密码错误留给 service 层映射为 AUTH_FAILED。 // LoginPassword 统一把短号不存在、未设置密码和密码错误留给 service 层映射为 AUTH_FAILED。
func (s *Server) LoginPassword(ctx context.Context, req *userv1.LoginPasswordRequest) (*userv1.AuthResponse, error) { func (s *Server) LoginPassword(ctx context.Context, req *userv1.LoginPasswordRequest) (*userv1.AuthResponse, error) {
ctx = contextWithApp(ctx, req.GetMeta()) ctx = contextWithApp(ctx, req.GetMeta())

View File

@ -14,7 +14,6 @@ activity_service_addr: "activity-service:13006"
mysql_auto_migrate: false mysql_auto_migrate: false
red_packet_expiry_worker: red_packet_expiry_worker:
enabled: true enabled: true
app_code: "lalu"
poll_interval: "5s" poll_interval: "5s"
batch_size: 50 batch_size: 50
projection_worker: projection_worker:

View File

@ -14,7 +14,6 @@ activity_service_addr: "ACTIVITY_SERVICE_HOST:13006"
mysql_auto_migrate: false mysql_auto_migrate: false
red_packet_expiry_worker: red_packet_expiry_worker:
enabled: true enabled: true
app_code: "lalu"
poll_interval: "5s" poll_interval: "5s"
batch_size: 50 batch_size: 50
projection_worker: projection_worker:

View File

@ -14,7 +14,6 @@ activity_service_addr: "127.0.0.1:13006"
mysql_auto_migrate: false mysql_auto_migrate: false
red_packet_expiry_worker: red_packet_expiry_worker:
enabled: true enabled: true
app_code: "lalu"
poll_interval: "5s" poll_interval: "5s"
batch_size: 50 batch_size: 50
projection_worker: projection_worker:
@ -148,7 +147,6 @@ external_recharge:
v5pay_return_url: "http://127.0.0.1:7001/recharge/index.html" v5pay_return_url: "http://127.0.0.1:7001/recharge/index.html"
external_recharge_reconcile_worker: external_recharge_reconcile_worker:
enabled: true enabled: true
app_code: "lalu"
poll_interval: "30s" poll_interval: "30s"
batch_size: 50 batch_size: 50
outbox_worker: outbox_worker:

View File

@ -18,7 +18,8 @@ func (a *App) startExternalRechargeReconcileWorker(ctx context.Context) {
defer ticker.Stop() defer ticker.Stop()
for { for {
// 三方回调是主链路;补偿 worker 只兜底 redirected 订单,真正入账仍由 service/storage 的幂等事务收敛。 // 三方回调是主链路;补偿 worker 只兜底 redirected 订单,真正入账仍由 service/storage 的幂等事务收敛。
_, err := a.walletSvc.ReconcileExternalRechargeOrders(ctx, a.externalRechargeReconcileWorkerCfg.AppCode, a.externalRechargeReconcileWorkerCfg.BatchSize) // 自动补偿不绑定租户;每笔订单进入 provider 查询和入账前会恢复自己的 app_code context。
_, err := a.walletSvc.ReconcileExternalRechargeOrders(ctx, "", a.externalRechargeReconcileWorkerCfg.BatchSize)
if err != nil && !errors.Is(err, context.Canceled) { if err != nil && !errors.Is(err, context.Canceled) {
logx.Error(ctx, "external_recharge_reconcile_failed", err, slog.String("worker_id", workerID)) logx.Error(ctx, "external_recharge_reconcile_failed", err, slog.String("worker_id", workerID))
} }

View File

@ -18,7 +18,8 @@ func (a *App) startRedPacketExpiryWorker(ctx context.Context) {
defer ticker.Stop() defer ticker.Stop()
for { for {
// 过期退款必须进入 wallet service 用例,保证红包状态、退款流水和 outbox 同一事务提交。 // 过期退款必须进入 wallet service 用例,保证红包状态、退款流水和 outbox 同一事务提交。
result, err := a.walletSvc.ExpireRedPackets(ctx, a.redPacketExpiryWorkerCfg.AppCode, a.redPacketExpiryWorkerCfg.BatchSize) // 空 app_code 是后台 worker 的“所有租户”语义repository 会按候选记录的真实 app_code 分事务退款。
result, err := a.walletSvc.ExpireRedPackets(ctx, "", a.redPacketExpiryWorkerCfg.BatchSize)
if err != nil && !errors.Is(err, context.Canceled) { if err != nil && !errors.Is(err, context.Canceled) {
logx.Error(ctx, "red_packet_expiry_failed", err, slog.String("worker_id", workerID)) logx.Error(ctx, "red_packet_expiry_failed", err, slog.String("worker_id", workerID))
} }

View File

@ -60,7 +60,6 @@ type Config struct {
// RedPacketExpiryWorkerConfig 保存红包过期退款 worker 策略。 // RedPacketExpiryWorkerConfig 保存红包过期退款 worker 策略。
type RedPacketExpiryWorkerConfig struct { type RedPacketExpiryWorkerConfig struct {
Enabled bool `yaml:"enabled"` Enabled bool `yaml:"enabled"`
AppCode string `yaml:"app_code"`
PollInterval time.Duration `yaml:"poll_interval"` PollInterval time.Duration `yaml:"poll_interval"`
BatchSize int32 `yaml:"batch_size"` BatchSize int32 `yaml:"batch_size"`
} }
@ -270,7 +269,6 @@ type ExternalRechargeConfig struct {
// ExternalRechargeReconcileWorkerConfig 保存 H5 外部充值查单补偿策略。 // ExternalRechargeReconcileWorkerConfig 保存 H5 外部充值查单补偿策略。
type ExternalRechargeReconcileWorkerConfig struct { type ExternalRechargeReconcileWorkerConfig struct {
Enabled bool `yaml:"enabled"` Enabled bool `yaml:"enabled"`
AppCode string `yaml:"app_code"`
PollInterval time.Duration `yaml:"poll_interval"` PollInterval time.Duration `yaml:"poll_interval"`
BatchSize int `yaml:"batch_size"` BatchSize int `yaml:"batch_size"`
} }
@ -287,7 +285,6 @@ func Default() Config {
ActivityServiceAddr: "127.0.0.1:13006", ActivityServiceAddr: "127.0.0.1:13006",
RedPacketExpiryWorker: RedPacketExpiryWorkerConfig{ RedPacketExpiryWorker: RedPacketExpiryWorkerConfig{
Enabled: true, Enabled: true,
AppCode: "lalu",
PollInterval: 5 * time.Second, PollInterval: 5 * time.Second,
BatchSize: 50, BatchSize: 50,
}, },
@ -339,7 +336,6 @@ func Default() Config {
}, },
ExternalRechargeReconcileWorker: ExternalRechargeReconcileWorkerConfig{ ExternalRechargeReconcileWorker: ExternalRechargeReconcileWorkerConfig{
Enabled: true, Enabled: true,
AppCode: "lalu",
PollInterval: 30 * time.Second, PollInterval: 30 * time.Second,
BatchSize: 50, BatchSize: 50,
}, },
@ -434,10 +430,6 @@ func Load(path string) (Config, error) {
if cfg.ActivityServiceAddr == "" { if cfg.ActivityServiceAddr == "" {
cfg.ActivityServiceAddr = "127.0.0.1:13006" cfg.ActivityServiceAddr = "127.0.0.1:13006"
} }
cfg.RedPacketExpiryWorker.AppCode = strings.TrimSpace(cfg.RedPacketExpiryWorker.AppCode)
if cfg.RedPacketExpiryWorker.AppCode == "" {
cfg.RedPacketExpiryWorker.AppCode = Default().RedPacketExpiryWorker.AppCode
}
if cfg.RedPacketExpiryWorker.PollInterval <= 0 { if cfg.RedPacketExpiryWorker.PollInterval <= 0 {
cfg.RedPacketExpiryWorker.PollInterval = Default().RedPacketExpiryWorker.PollInterval cfg.RedPacketExpiryWorker.PollInterval = Default().RedPacketExpiryWorker.PollInterval
} }
@ -604,9 +596,6 @@ func dotEnvCandidates(start string) []string {
} }
func normalizeExternalRechargeReconcileWorkerConfig(cfg ExternalRechargeReconcileWorkerConfig, defaults ExternalRechargeReconcileWorkerConfig) ExternalRechargeReconcileWorkerConfig { func normalizeExternalRechargeReconcileWorkerConfig(cfg ExternalRechargeReconcileWorkerConfig, defaults ExternalRechargeReconcileWorkerConfig) ExternalRechargeReconcileWorkerConfig {
if cfg.AppCode = strings.TrimSpace(cfg.AppCode); cfg.AppCode == "" {
cfg.AppCode = defaults.AppCode
}
if cfg.PollInterval <= 0 { if cfg.PollInterval <= 0 {
cfg.PollInterval = defaults.PollInterval cfg.PollInterval = defaults.PollInterval
} }

View File

@ -25,7 +25,7 @@ func TestLoadLocalEnablesOutboxMQ(t *testing.T) {
if !cfg.OutboxWorker.Enabled || !cfg.RealtimeOutboxWorker.Enabled || !cfg.ProjectionWorker.Enabled { if !cfg.OutboxWorker.Enabled || !cfg.RealtimeOutboxWorker.Enabled || !cfg.ProjectionWorker.Enabled {
t.Fatalf("local config must enable wallet outbox workers: outbox=%+v realtime=%+v projection=%+v", cfg.OutboxWorker, cfg.RealtimeOutboxWorker, cfg.ProjectionWorker) t.Fatalf("local config must enable wallet outbox workers: outbox=%+v realtime=%+v projection=%+v", cfg.OutboxWorker, cfg.RealtimeOutboxWorker, cfg.ProjectionWorker)
} }
if !cfg.ExternalRechargeReconcileWorker.Enabled || cfg.ExternalRechargeReconcileWorker.BatchSize != 50 || cfg.ExternalRechargeReconcileWorker.AppCode != "lalu" { if !cfg.ExternalRechargeReconcileWorker.Enabled || cfg.ExternalRechargeReconcileWorker.BatchSize != 50 {
t.Fatalf("local config must enable external recharge reconcile worker: %+v", cfg.ExternalRechargeReconcileWorker) t.Fatalf("local config must enable external recharge reconcile worker: %+v", cfg.ExternalRechargeReconcileWorker)
} }
if cfg.OutboxWorker.Concurrency != 1 || cfg.RealtimeOutboxWorker.Concurrency != 2 { if cfg.OutboxWorker.Concurrency != 1 || cfg.RealtimeOutboxWorker.Concurrency != 2 {

View File

@ -106,6 +106,14 @@ type Receipt struct {
GiftIncomeCoinAmount int64 GiftIncomeCoinAmount int64
// GiftIncomeBalanceAfter 是收礼人 COIN 入账后的余额;没有返币时为 0。 // GiftIncomeBalanceAfter 是收礼人 COIN 入账后的余额;没有返币时为 0。
GiftIncomeBalanceAfter int64 GiftIncomeBalanceAfter int64
// Recharge* 是扣费事务固化的 sender 充值画像。它们跟随 gift transaction metadata 幂等重放,
// 防止 lucky-gift 因重试时间不同进入另一概率层或丢失“充值后五分钟”加成。
RechargeSevenDayCoins int64
RechargeThirtyDayCoins int64
LastRechargedAtMS int64
// PaidAtMS 是 wallet owner 创建成功交易行的 UTC epoch ms。它跟 transaction_id 一起构成不可变扣费事实,
// room 恢复和 lucky 重放必须复用它,不能改用 saga 创建时间或恢复执行时间。
PaidAtMS int64
} }
// BatchGiftTargetReceipt 保留批量送礼里每个目标的独立交易回执。 // BatchGiftTargetReceipt 保留批量送礼里每个目标的独立交易回执。

View File

@ -15,19 +15,20 @@ func (s *Service) ReconcileExternalRechargeOrders(ctx context.Context, appCode s
if limit <= 0 { if limit <= 0 {
return 0, nil return 0, nil
} }
ctx = appcode.WithContext(ctx, appcode.Normalize(appCode)) appCode = strings.ToLower(strings.TrimSpace(appCode))
orders, err := s.repository.ListExternalRechargeOrdersForReconcile(ctx, appcode.FromContext(ctx), limit) orders, err := s.repository.ListExternalRechargeOrdersForReconcile(ctx, appCode, limit)
if err != nil { if err != nil {
return 0, err return 0, err
} }
processed := 0 processed := 0
for _, order := range orders { for _, order := range orders {
// 跨租户扫描后必须恢复订单自己的 context后续幂等流水和账户更新不能落到默认 lalu。
if _, err := s.refreshMifaPayRechargeOrder(ctx, order); err != nil { orderCtx := appcode.WithContext(ctx, order.AppCode)
if _, err := s.refreshMifaPayRechargeOrder(orderCtx, order); err != nil {
processed++ processed++
continue continue
} }
if _, err := s.refreshV5PayRechargeOrder(ctx, order); err != nil { if _, err := s.refreshV5PayRechargeOrder(orderCtx, order); err != nil {
processed++ processed++
continue continue
} }

View File

@ -127,8 +127,12 @@ func (s *Service) ExpireRedPackets(ctx context.Context, appCode string, batchSiz
if s.repository == nil { if s.repository == nil {
return ledger.RedPacketExpireResult{}, xerr.New(xerr.Unavailable, "wallet repository is not configured") return ledger.RedPacketExpireResult{}, xerr.New(xerr.Unavailable, "wallet repository is not configured")
} }
// 空 app_code 专用于内部 worker 的跨租户扫描;显式 RPC 仍归一化并保持单租户边界。
appCode = strings.ToLower(strings.TrimSpace(appCode))
if appCode != "" {
appCode = appcode.Normalize(appCode) appCode = appcode.Normalize(appCode)
ctx = appcode.WithContext(ctx, appCode) ctx = appcode.WithContext(ctx, appCode)
}
return s.repository.ExpireRedPackets(ctx, appCode, batchSize) return s.repository.ExpireRedPackets(ctx, appCode, batchSize)
} }

View File

@ -399,6 +399,15 @@ func TestBatchDebitGiftSettlesAllTargetsAtomically(t *testing.T) {
if again.Aggregate.CoinSpent != receipt.Aggregate.CoinSpent || again.Targets[0].Receipt.TransactionID != receipt.Targets[0].Receipt.TransactionID || repository.CountRows("wallet_transactions", "command_id IN (?, ?)", "cmd-gift-batch:target:10002", "cmd-gift-batch:target:10003") != 2 { if again.Aggregate.CoinSpent != receipt.Aggregate.CoinSpent || again.Targets[0].Receipt.TransactionID != receipt.Targets[0].Receipt.TransactionID || repository.CountRows("wallet_transactions", "command_id IN (?, ?)", "cmd-gift-batch:target:10002", "cmd-gift-batch:target:10003") != 2 {
t.Fatalf("batch idempotency mismatch: first=%+v again=%+v", receipt, again) t.Fatalf("batch idempotency mismatch: first=%+v again=%+v", receipt, again)
} }
if receipt.Aggregate.PaidAtMS <= 0 || again.Aggregate.PaidAtMS != receipt.Aggregate.PaidAtMS {
t.Fatalf("batch aggregate paid_at_ms drifted: first=%d replay=%d", receipt.Aggregate.PaidAtMS, again.Aggregate.PaidAtMS)
}
for index, target := range receipt.Targets {
walletCreatedAtMS := repository.TransactionCreatedAtMS(target.CommandID)
if target.Receipt.PaidAtMS != walletCreatedAtMS || again.Targets[index].Receipt.PaidAtMS != walletCreatedAtMS || target.Receipt.PaidAtMS != receipt.Aggregate.PaidAtMS {
t.Fatalf("batch target paid_at_ms is not stable wallet fact: target=%s created=%d first=%d replay=%d aggregate=%d", target.CommandID, walletCreatedAtMS, target.Receipt.PaidAtMS, again.Targets[index].Receipt.PaidAtMS, receipt.Aggregate.PaidAtMS)
}
}
for _, userID := range []int64{10002, 10003} { for _, userID := range []int64{10002, 10003} {
balances, err := svc.GetBalances(context.Background(), userID, []string{ledger.AssetCoin}) balances, err := svc.GetBalances(context.Background(), userID, []string{ledger.AssetCoin})
if err != nil { if err != nil {
@ -1576,6 +1585,8 @@ func TestDebitGiftIsIdempotent(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("first debit failed: %v", err) t.Fatalf("first debit failed: %v", err)
} }
// 模拟旧版本 transaction metadata 未写 paid_at_ms重放仍必须读取 wallet_transactions.created_at_ms。
repository.RemoveGiftPaidAtMetadata(command.CommandID)
second, err := svc.DebitGift(context.Background(), command) second, err := svc.DebitGift(context.Background(), command)
if err != nil { if err != nil {
t.Fatalf("second debit failed: %v", err) t.Fatalf("second debit failed: %v", err)
@ -1584,6 +1595,11 @@ func TestDebitGiftIsIdempotent(t *testing.T) {
if first.BillingReceiptID != second.BillingReceiptID || second.BalanceAfter != 80 || second.CoinSpent != 20 { if first.BillingReceiptID != second.BillingReceiptID || second.BalanceAfter != 80 || second.CoinSpent != 20 {
t.Fatalf("idempotency mismatch: first=%+v second=%+v", first, second) t.Fatalf("idempotency mismatch: first=%+v second=%+v", first, second)
} }
transactionCreatedAtMS := repository.TransactionCreatedAtMS(command.CommandID)
if first.PaidAtMS <= 0 || first.PaidAtMS != transactionCreatedAtMS || second.PaidAtMS != transactionCreatedAtMS {
// paid_at_ms 必须来自首次 wallet transaction而不是每次 RPC 重放的服务时钟。
t.Fatalf("gift paid_at_ms is not stable wallet fact: created=%d first=%d second=%d", transactionCreatedAtMS, first.PaidAtMS, second.PaidAtMS)
}
if got := repository.CountRows("wallet_transactions", "command_id = ?", command.CommandID); got != 1 { if got := repository.CountRows("wallet_transactions", "command_id = ?", command.CommandID); got != 1 {
t.Fatalf("idempotent command should write one transaction, got %d", got) t.Fatalf("idempotent command should write one transaction, got %d", got)
} }
@ -1907,6 +1923,46 @@ func TestRedPacketLifecycleUsesRealMySQL(t *testing.T) {
} }
} }
func TestExpireRedPacketsWithoutAppCodeRefundsEveryTenant(t *testing.T) {
repository := mysqltest.NewRepository(t)
repository.SetBalanceForApp("lalu", 19101, 100)
repository.SetBalanceForApp("huwaa", 19102, 100)
svc := walletservice.New(repository)
create := func(appCode string, commandID string, senderUserID int64) ledger.RedPacketCreateReceipt {
t.Helper()
receipt, err := svc.CreateRedPacket(context.Background(), ledger.RedPacketCreateCommand{
AppCode: appCode, CommandID: commandID, SenderUserID: senderUserID,
RoomID: appCode + "_room", RegionID: 1, PacketType: ledger.RedPacketTypeNormal,
TotalAmount: 10, PacketCount: 1,
})
if err != nil {
t.Fatalf("create %s red packet failed: %v", appCode, err)
}
repository.ExpireRedPacket(appCode, receipt.Packet.PacketID)
return receipt
}
lalu := create("lalu", "cmd-red-packet-all-lalu", 19101)
huwaa := create("huwaa", "cmd-red-packet-all-huwaa", 19102)
result, err := svc.ExpireRedPackets(context.Background(), "", 10)
if err != nil {
t.Fatalf("ExpireRedPackets all tenants failed: %v", err)
}
if result.ExpiredCount != 2 || result.RefundedAmount != 20 {
t.Fatalf("all-tenant expiry result mismatch: %+v", result)
}
for _, packet := range []struct {
appCode string
packetID string
}{{"lalu", lalu.Packet.PacketID}, {"huwaa", huwaa.Packet.PacketID}} {
got, err := svc.GetRedPacket(context.Background(), packet.appCode, packet.packetID, 0, false)
if err != nil || got.Status != ledger.RedPacketStatusRefunded {
t.Fatalf("%s packet must be refunded: packet=%+v err=%v", packet.appCode, got, err)
}
}
}
func TestWalletOutboxClaimSeparatesRealtimeEventTypes(t *testing.T) { func TestWalletOutboxClaimSeparatesRealtimeEventTypes(t *testing.T) {
repository := mysqltest.NewRepository(t) repository := mysqltest.NewRepository(t)
repository.SetBalance(19101, 1000) repository.SetBalance(19101, 1000)

View File

@ -14,6 +14,20 @@ import (
"hyapp/services/wallet-service/internal/testutil/mysqltest" "hyapp/services/wallet-service/internal/testutil/mysqltest"
) )
func TestVipDailyCoinRebateMissingProgramIsNoOp(t *testing.T) {
repository := mysqltest.NewRepository(t)
service := walletservice.New(repository)
taskDay := time.Now().UTC().Format("2006-01-02")
result, err := service.ProcessVipDailyCoinRebateBatch(
appcode.WithContext(context.Background(), "huwaa"),
ledger.ProcessVipDailyCoinRebateBatchCommand{AppCode: "huwaa", TaskDay: taskDay, BatchSize: 100},
)
if err != nil || result.HasMore || result.CreatedCount != 0 || result.ScannedCount != 0 {
t.Fatalf("missing VIP program must be an empty successful batch: result=%+v err=%v", result, err)
}
}
// TestVipDailyCoinRebateUTCWindowSnapshotAndClaim 覆盖返现最关键的不可变边界: // TestVipDailyCoinRebateUTCWindowSnapshotAndClaim 覆盖返现最关键的不可变边界:
// UTC 日切资格、run 配置快照、分页重放、App 隔离、available outbox 和并发领取仅入账一次。 // UTC 日切资格、run 配置快照、分页重放、App 隔离、available outbox 和并发领取仅入账一次。
func TestVipDailyCoinRebateUTCWindowSnapshotAndClaim(t *testing.T) { func TestVipDailyCoinRebateUTCWindowSnapshotAndClaim(t *testing.T) {

View File

@ -362,22 +362,25 @@ func (r *Repository) ListExternalRechargeOrdersForReconcile(ctx context.Context,
if r == nil || r.db == nil { if r == nil || r.db == nil {
return nil, xerr.New(xerr.Unavailable, "mysql repository is not configured") return nil, xerr.New(xerr.Unavailable, "mysql repository is not configured")
} }
ctx = contextWithCommandApp(ctx, appCode)
if limit <= 0 { if limit <= 0 {
return nil, nil return nil, nil
} }
if limit > 500 { if limit > 500 {
limit = 500 limit = 500
} }
rows, err := r.db.QueryContext(ctx, externalRechargeOrderSelectSQL()+` appCode = strings.ToLower(strings.TrimSpace(appCode))
WHERE app_code = ? where := `
AND provider_code IN (?, ?) WHERE provider_code IN (?, ?)
AND status = ? AND status = ?
ORDER BY updated_at_ms ASC, created_at_ms ASC `
LIMIT ?`, args := []any{ledger.PaymentProviderMifaPay, ledger.PaymentProviderV5Pay, ledger.ExternalRechargeStatusRedirected}
appcode.FromContext(ctx), ledger.PaymentProviderMifaPay, ledger.PaymentProviderV5Pay, if appCode != "" {
ledger.ExternalRechargeStatusRedirected, limit, where += ` AND app_code = ?`
) args = append(args, appcode.Normalize(appCode))
}
where += ` ORDER BY updated_at_ms ASC, created_at_ms ASC, app_code ASC LIMIT ?`
args = append(args, limit)
rows, err := r.db.QueryContext(ctx, externalRechargeOrderSelectSQL()+where, args...)
if err != nil { if err != nil {
return nil, err return nil, err
} }

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