From e3bf386acfbe156468a41ca1a7229afc8e093283 Mon Sep 17 00:00:00 2001 From: zhx Date: Fri, 17 Jul 2026 16:16:33 +0800 Subject: [PATCH] fix: use wallet host income for lucky gifts --- api/proto/luckygift/v1/luckygift.pb.go | 3 ++- api/proto/luckygift/v1/luckygift.proto | 3 ++- .../internal/domain/luckygift/lucky_gift.go | 3 ++- .../storage/mysql/external_repository.go | 3 ++- .../mysql/lucky_gift_dynamic_repository.go | 4 ++-- .../lucky_gift_dynamic_repository_test.go | 10 +++++----- .../mysql/lucky_gift_dynamic_strategy.go | 20 +++++++++---------- .../mysql/lucky_gift_dynamic_strategy_test.go | 13 +++++++----- .../transport/grpc/lucky_gift_server.go | 3 ++- .../internal/room/service/gift_lucky.go | 4 +++- 10 files changed, 38 insertions(+), 28 deletions(-) diff --git a/api/proto/luckygift/v1/luckygift.pb.go b/api/proto/luckygift/v1/luckygift.pb.go index c8560c23..593a8a28 100644 --- a/api/proto/luckygift/v1/luckygift.pb.go +++ b/api/proto/luckygift/v1/luckygift.pb.go @@ -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。 diff --git a/api/proto/luckygift/v1/luckygift.proto b/api/proto/luckygift/v1/luckygift.proto index 1dc5c275..f913fdd8 100644 --- a/api/proto/luckygift/v1/luckygift.proto +++ b/api/proto/luckygift/v1/luckygift.proto @@ -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。 diff --git a/services/lucky-gift-service/internal/domain/luckygift/lucky_gift.go b/services/lucky-gift-service/internal/domain/luckygift/lucky_gift.go index 04c75eb3..9ba812a6 100644 --- a/services/lucky-gift-service/internal/domain/luckygift/lucky_gift.go +++ b/services/lucky-gift-service/internal/domain/luckygift/lucky_gift.go @@ -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 diff --git a/services/lucky-gift-service/internal/storage/mysql/external_repository.go b/services/lucky-gift-service/internal/storage/mysql/external_repository.go index c6e34b27..6b56180b 100644 --- a/services/lucky-gift-service/internal/storage/mysql/external_repository.go +++ b/services/lucky-gift-service/internal/storage/mysql/external_repository.go @@ -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 或客户端自报字段猜测更安全。后续若扩协议,必须把签名覆盖的充值事实显式入契约。 diff --git a/services/lucky-gift-service/internal/storage/mysql/lucky_gift_dynamic_repository.go b/services/lucky-gift-service/internal/storage/mysql/lucky_gift_dynamic_repository.go index 0bf08251..f200407c 100644 --- a/services/lucky-gift-service/internal/storage/mysql/lucky_gift_dynamic_repository.go +++ b/services/lucky-gift-service/internal/storage/mysql/lucky_gift_dynamic_repository.go @@ -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") } diff --git a/services/lucky-gift-service/internal/storage/mysql/lucky_gift_dynamic_repository_test.go b/services/lucky-gift-service/internal/storage/mysql/lucky_gift_dynamic_repository_test.go index 5022b585..dd5a552c 100644 --- a/services/lucky-gift-service/internal/storage/mysql/lucky_gift_dynamic_repository_test.go +++ b/services/lucky-gift-service/internal/storage/mysql/lucky_gift_dynamic_repository_test.go @@ -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 { diff --git a/services/lucky-gift-service/internal/storage/mysql/lucky_gift_dynamic_strategy.go b/services/lucky-gift-service/internal/storage/mysql/lucky_gift_dynamic_strategy.go index d35552a3..98438110 100644 --- a/services/lucky-gift-service/internal/storage/mysql/lucky_gift_dynamic_strategy.go +++ b/services/lucky-gift-service/internal/storage/mysql/lucky_gift_dynamic_strategy.go @@ -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 联合分配整批的三个资金桶:每一抽都先满足 diff --git a/services/lucky-gift-service/internal/storage/mysql/lucky_gift_dynamic_strategy_test.go b/services/lucky-gift-service/internal/storage/mysql/lucky_gift_dynamic_strategy_test.go index 6e0517fb..60fccd6f 100644 --- a/services/lucky-gift-service/internal/storage/mysql/lucky_gift_dynamic_strategy_test.go +++ b/services/lucky-gift-service/internal/storage/mysql/lucky_gift_dynamic_strategy_test.go @@ -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") } } diff --git a/services/lucky-gift-service/internal/transport/grpc/lucky_gift_server.go b/services/lucky-gift-service/internal/transport/grpc/lucky_gift_server.go index 84ca3800..3e2171a6 100644 --- a/services/lucky-gift-service/internal/transport/grpc/lucky_gift_server.go +++ b/services/lucky-gift-service/internal/transport/grpc/lucky_gift_server.go @@ -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(), } } diff --git a/services/room-service/internal/room/service/gift_lucky.go b/services/room-service/internal/room/service/gift_lucky.go index a61a0ffb..98e953b6 100644 --- a/services/room-service/internal/room/service/gift_lucky.go +++ b/services/room-service/internal/room/service/gift_lucky.go @@ -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 }