fix: use wallet host income for lucky gifts

This commit is contained in:
zhx 2026-07-17 16:16:33 +08:00
parent e705d8cc44
commit e3bf386acf
10 changed files with 38 additions and 28 deletions

View File

@ -126,7 +126,8 @@ type LuckyGiftMeta struct {
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 用它校验规则主播拆账,缺失或比例不一致均拒绝开奖。
// gift_income_coins 是滚动兼容保留的旧字段名,实际承载 wallet 按后台礼物比例配置结算的
// host_period_diamond_added。幸运礼物服务只能消费该 owner 回执,不能再用幸运礼物规则里的主播比例覆盖它。
GiftIncomeCoins int64 `protobuf:"varint,22,opt,name=gift_income_coins,json=giftIncomeCoins,proto3" json:"gift_income_coins,omitempty"`
// user_registered_at_ms 是 user-service 在送礼入口返回的账号创建事实快照。
// dynamic_v3 只用它判断闭窗时账号是否已满 48 小时;缺失时普通开奖继续,但补偿大奖资格 fail-close。

View File

@ -40,7 +40,8 @@ message LuckyGiftMeta {
int64 recharge_7d_coins = 19;
int64 recharge_30d_coins = 20;
int64 last_recharged_at_ms = 21;
// gift_income_coins wallet dynamic_v3
// gift_income_coins wallet
// host_period_diamond_added owner
int64 gift_income_coins = 22;
// user_registered_at_ms user-service
// dynamic_v3 48 fail-close

View File

@ -211,7 +211,8 @@ type DrawCommand struct {
Recharge7DCoins int64
Recharge30DCoins int64
LastRechargedAtMS int64
GiftIncomeCoins int64
// HostPeriodDiamondAdded 是 wallet 按后台礼物比例配置实际入账的主播钻石,资金拆分必须以该 owner 事实为准。
HostPeriodDiamondAdded int64
// Sender* 是扣费时刻的公开展示快照owner outbox 必须携带快照,避免下游为一条飘屏同步反查 user-service。
SenderName string
SenderAvatar string

View File

@ -332,7 +332,8 @@ func (r *Repository) executeExternalDynamicGiftEconomy(ctx context.Context, tx *
CoinSpent: cmd.TotalAmount,
PaidAtMS: cmd.PaidAtMS,
UserRegisteredAtMS: cmd.UserRegisteredAtMS,
GiftIncomeCoins: split.AnchorReturnCoins,
// 外部 App 没有 HyApp wallet 回执,只能继续使用已签名外部金额按本规则拆出的主播份额。
HostPeriodDiamondAdded: split.AnchorReturnCoins,
}
// 当前外部协议没有可验证的充值 owner 快照;保持 0/0 会落 novice 且关闭五分钟加成,
// 比从 metadata 或客户端自报字段猜测更安全。后续若扩协议,必须把签名覆盖的充值事实显式入契约。

View File

@ -147,7 +147,7 @@ func (r *Repository) executeDynamicLuckyGiftDrawBatch(ctx context.Context, tx *s
if err != nil {
return nil, err
}
fundSplit, err := luckyDynamicFundSplit(cmd.CoinSpent, cmd.GiftIncomeCoins, strategyConfig)
fundSplit, err := luckyDynamicFundSplit(cmd.CoinSpent, cmd.HostPeriodDiamondAdded, strategyConfig)
if err != nil {
return nil, err
}
@ -204,7 +204,7 @@ func (r *Repository) executeDynamicLuckyGiftDrawBatch(ctx context.Context, tx *s
return nil, err
}
unitSplit := unitSplits[index-1]
unitCommand.GiftIncomeCoins = unitSplit.AnchorReturnCoins
unitCommand.HostPeriodDiamondAdded = unitSplit.AnchorReturnCoins
if unitCommand.CoinSpent <= 0 {
return nil, xerr.New(xerr.InvalidArgument, "dynamic lucky gift unit spend must be positive")
}

View File

@ -48,7 +48,7 @@ func TestDynamicLuckyGiftMySQLBatchAndExternalPaths(t *testing.T) {
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,
GiftCount: 99, CoinSpent: 100, HostPeriodDiamondAdded: 1, PaidAtMS: now,
}
missingWalletPaidAt := command
missingWalletPaidAt.CommandID = "dynamic-missing-wallet-paid-at"
@ -330,7 +330,7 @@ func assertDynamicTokenSurvivesUTCDayBoundary(t *testing.T, repo *Repository, db
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,
GiftCount: 1, CoinSpent: 5_000, HostPeriodDiamondAdded: 50, PaidAtMS: dayOneMS,
}
if _, err := repo.ExecuteLuckyGiftDraw(ctx, earn, dayOneMS); err != nil {
t.Fatalf("earn milestone token: %v", err)
@ -350,7 +350,7 @@ func assertDynamicTokenSurvivesUTCDayBoundary(t *testing.T, repo *Repository, db
retain := earn
retain.CommandID = "token-retain-next-day"
retain.CoinSpent = 1
retain.GiftIncomeCoins = 0
retain.HostPeriodDiamondAdded = 0
retain.PaidAtMS = dayTwoMS
if _, err := repo.ExecuteLuckyGiftDraw(ctx, retain, dayTwoMS); err != nil {
t.Fatalf("retain token across UTC day: %v", err)
@ -408,7 +408,7 @@ func assertDynamicSpendBatchRollbackAndRestart(t *testing.T, repo *Repository, d
command := domain.DrawCommand{
CommandID: "dual-spend-batch-command", PoolID: published.PoolID, UserID: 4_040, TargetUserID: 1,
DeviceID: "dual-spend-batch-device", RoomID: "dual-spend-batch-room", AnchorID: "dual-spend-batch-anchor", GiftID: "super-lucky",
GiftCount: 3, CoinSpent: 30, GiftIncomeCoins: 0, PaidAtMS: nowMS,
GiftCount: 3, CoinSpent: 30, HostPeriodDiamondAdded: 0, PaidAtMS: nowMS,
}
const rollbackTrigger = "trg_lucky_dual_spend_rollback"
if _, err := db.Exec("DROP TRIGGER IF EXISTS " + rollbackTrigger); err != nil {
@ -938,7 +938,7 @@ func TestLuckyDynamicDualSourcesSharePoolRTPRiskAndAuditAccounting(t *testing.T)
command := domain.DrawCommand{
CommandID: label, PoolID: runtimeConfig.PoolID, GiftID: runtimeConfig.GiftID, GiftCount: 1,
UserID: 1001, TargetUserID: 2001, RoomID: "room-v5", AnchorID: "anchor-v5",
CoinSpent: 100, GiftIncomeCoins: 10, PaidAtMS: paidAtMS,
CoinSpent: 100, HostPeriodDiamondAdded: 10, PaidAtMS: paidAtMS,
}
split := domain.StrategyFundSplit{PublicPoolCoins: 89, ProfitPoolCoins: 1, AnchorReturnCoins: 10}
if err := (&Repository{}).stageLuckyDynamicDecision("lalu", runtimeConfig, runtimeConfig.GiftPrice, command, split, decision, paidAtMS, &state); err != nil {

View File

@ -144,22 +144,22 @@ func luckyDynamicStrategyConfig(config domain.Config) domain.StrategyConfig {
}
}
// luckyDynamicFundSplit 以不可变规则做唯一拆账,并校验 wallet 已经发给收礼人的 COIN 与规则主播份额完全一致
// dynamic_v3 不能把旧 room 缺字段解释成 0也不能把 App 钱包配置的其他返币比例静默塞进盈利池;
// 启用新规则前必须先完成 wallet/room 契约滚动升级并让礼物返币配置与本规则一致
// luckyDynamicFundSplit 以 wallet 已按后台礼物比例配置实际入账的主播钻石为唯一主播份额
// 幸运礼物规则只决定公共奖池比例;主播份额变化后的剩余金额自然进入平台利润,不能再用规则里的
// anchor_rate_ppm 覆盖 wallet owner 事实,也不能把“返还金币比例”误当成主播收益
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,
)
if actualAnchorIncome < 0 || actualAnchorIncome > totalSpent || configured.PublicPoolCoins > totalSpent-actualAnchorIncome {
return domain.StrategyFundSplit{}, fmt.Errorf("dynamic lucky gift wallet anchor income is invalid: spent=%d anchor=%d", totalSpent, actualAnchorIncome)
}
return configured, nil
return domain.StrategyFundSplit{
PublicPoolCoins: configured.PublicPoolCoins,
ProfitPoolCoins: totalSpent - configured.PublicPoolCoins - actualAnchorIncome,
AnchorReturnCoins: actualAnchorIncome,
}, nil
}
// luckyDynamicUnitFundSplits 联合分配整批的三个资金桶:每一抽都先满足

View File

@ -102,15 +102,18 @@ func TestLuckyDynamicUnitFundSplitsConserveEveryDrawAndBatchBucket(t *testing.T)
}
}
func TestLuckyDynamicFundSplitRejectsWalletAnchorMismatch(t *testing.T) {
func TestLuckyDynamicFundSplitUsesWalletAnchorIncome(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 split, err := luckyDynamicFundSplit(100, 0, config); err != nil || split != (domain.StrategyFundSplit{PublicPoolCoins: 98, ProfitPoolCoins: 2, AnchorReturnCoins: 0}) {
t.Fatalf("zero wallet anchor split=%+v err=%v", split, err)
}
if _, err := luckyDynamicFundSplit(100, 10, config); err == nil {
t.Fatal("wallet anchor ratio different from rule was accepted")
if split, err := luckyDynamicFundSplit(100, 2, config); err != nil || split != (domain.StrategyFundSplit{PublicPoolCoins: 98, ProfitPoolCoins: 0, AnchorReturnCoins: 2}) {
t.Fatalf("wallet-authoritative split=%+v err=%v", split, err)
}
if _, err := luckyDynamicFundSplit(100, 3, config); err == nil {
t.Fatal("wallet anchor that exceeds the conserved remainder was accepted")
}
}

View File

@ -132,7 +132,8 @@ func luckyDrawCommandFromProto(meta *luckygiftv1.LuckyGiftMeta) domain.DrawComma
Recharge7DCoins: meta.GetRecharge_7DCoins(),
Recharge30DCoins: meta.GetRecharge_30DCoins(),
LastRechargedAtMS: meta.GetLastRechargedAtMs(),
GiftIncomeCoins: meta.GetGiftIncomeCoins(),
// protobuf 为滚动发布保留旧字段号和 getter 名domain 内只暴露真实的 wallet owner 语义。
HostPeriodDiamondAdded: meta.GetGiftIncomeCoins(),
}
}

View File

@ -125,7 +125,9 @@ func luckyGiftMetaForTarget(ctx context.Context, meta *roomv1.RequestMeta, cmd c
Recharge_7DCoins: targetBilling.Billing.GetRechargeSevenDayCoins(),
Recharge_30DCoins: targetBilling.Billing.GetRechargeThirtyDayCoins(),
LastRechargedAtMs: targetBilling.Billing.GetLastRechargedAtMs(),
GiftIncomeCoins: targetBilling.Billing.GetGiftIncomeCoinAmount(),
// LuckyGiftMeta.gift_income_coins 是滚动兼容保留的旧字段名。这里必须传 wallet 已按后台
// “主播钻石比例”实际入账的钻石,不能误传独立的“返还金币比例”结果,否则 0% 返币会阻断开奖。
GiftIncomeCoins: targetBilling.Billing.GetHostPeriodDiamondAdded(),
}, nil
}