新功能(#3): 抽奖 Stage 1 handler 真实业务对接 + 邀请钩子
Closes HIF-3 (阶段 PR B) PR B:handler 真实业务对接 + 邀请钩子(迭代含 R1 修复) - 迁移 02157_lottery_grant_ledger:external_ref UNIQUE 作为发奖幂等键 - log.CommissionTypeLottery=339(架构师批准的新常量) - DispatchRequest.IdempotencyKey(架构师 review 建议第 2 条) - GrantLedger + LedgerService.Reserve:INSERT ON CONFLICT DO NOTHING 幂等 upsert - VPNDurationHandler:ResolveEffectiveUser 归位家庭 owner + UpdateSubscribe,ExpireTime 三分支对齐 grantGiftDays - CommissionHandler:UpdateCommission + WriteCommissionLog(339),发给中奖者本人,不做家庭组归位 - Handler 从 model 层迁到 logic 层(避免 model → logic 反向依赖);noop 保留在 model 层 - InviteHook:fire-and-forget 独立 goroutine + 10s timeout,扫 running 活动的 invite_success 源 - ServiceContext 新增 LotteryChance / LotteryLedger / LotteryInviteHook - activateOrderLogic.handleCommission 两条分支通过 invokeInviteHookIfEligible 助手触发,助手内统一 gate IsNew(架构师 R1 打回后的修复) 架构师 R1 打回:branch B 未按"首次付款激活"gate,续费也会给 referer 发抽奖机会 → 已通过助手函数集中收拢,避免 branch A/B 判断漂移。 测试覆盖率:model 76.5% / handler 73.2% / hook 91.7%;补 4 个回归测试覆盖 IsNew 门槛(含关键的 DoesNotFireOnRenewal)。 线上仍零可见变更:无对外路由,钩子仅在活动 status=running 时生效,Stage 1 全流程无活动记录时 loadRunningActivities 返回空。
This commit is contained in:
@@ -1127,6 +1127,11 @@ func (l *ActivateOrderLogic) handleCommission(ctx context.Context, userInfo *use
|
||||
// 普通用户路径(佣金比例=0):只有首单才双方赠N天
|
||||
if orderInfo.IsNew {
|
||||
l.grantGiftDaysToBothParties(ctx, userInfo, orderInfo)
|
||||
// 抽奖钩子(Stage 1):首单成功即算邀请转化,即便本笔无佣金。
|
||||
// referer 通过 userInfo.RefererId 定位,orderNo 作幂等键。
|
||||
if userInfo != nil {
|
||||
l.invokeInviteHookIfEligible(ctx, userInfo.RefererId, orderInfo)
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -1214,6 +1219,11 @@ func (l *ActivateOrderLogic) handleCommission(ctx context.Context, userInfo *use
|
||||
)
|
||||
}
|
||||
|
||||
// 抽奖钩子(Stage 1):仅新购激活时触发(IsNew)。续费走留存路径不再发放
|
||||
// 机会,避免注册后弃号刷奖的黑产。branch B 内不再显式判 IsNew,全部通过
|
||||
// invokeInviteHookIfEligible 收拢,语义唯一入口。
|
||||
l.invokeInviteHookIfEligible(ctx, referer.Id, orderInfo)
|
||||
|
||||
// 有佣金路径:邀请人拿佣金,被邀请用户(首单)拿天数
|
||||
if orderInfo.IsNew {
|
||||
giftTarget := l.resolveGiftTargetUser(ctx, userInfo, orderInfo.SubscriptionUserId)
|
||||
@@ -1223,6 +1233,22 @@ func (l *ActivateOrderLogic) handleCommission(ctx context.Context, userInfo *use
|
||||
}
|
||||
}
|
||||
|
||||
// invokeInviteHookIfEligible 是抽奖邀请钩子的唯一入口。所有 handleCommission
|
||||
// 分支都通过它触发,避免"这里加了 IsNew 判断,那里忘了"的漂移。
|
||||
//
|
||||
// 门槛:仅在 orderInfo.IsNew=true(新购首次激活)时触发 —— 这是架构师锁定
|
||||
// 的"首次付款激活"决策。续费是留存事件而非转化事件,不发放抽奖机会(否则
|
||||
// 会催生注册后弃号刷奖的黑产)。
|
||||
func (l *ActivateOrderLogic) invokeInviteHookIfEligible(ctx context.Context, refererID int64, orderInfo *order.Order) {
|
||||
if l.svc == nil || l.svc.LotteryInviteHook == nil || orderInfo == nil {
|
||||
return
|
||||
}
|
||||
if !orderInfo.IsNew {
|
||||
return
|
||||
}
|
||||
l.svc.LotteryInviteHook.OnConversion(ctx, refererID, orderInfo.OrderNo)
|
||||
}
|
||||
|
||||
func (l *ActivateOrderLogic) grantGiftDaysToBothParties(ctx context.Context, referee *user.User, orderInfo *order.Order) {
|
||||
giftDays := l.svc.Config.Invite.GiftDays
|
||||
if giftDays <= 0 || referee == nil || referee.Id == 0 || referee.RefererId == 0 || orderInfo == nil {
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
package orderLogic
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
lotteryhook "github.com/perfect-panel/server/internal/logic/lottery/hook"
|
||||
"github.com/perfect-panel/server/internal/model/order"
|
||||
"github.com/perfect-panel/server/internal/svc"
|
||||
)
|
||||
|
||||
// recordingInviteHook 记录每次 OnConversion 调用,让测试直接断言时序与参数。
|
||||
type recordingInviteHook struct {
|
||||
mu sync.Mutex
|
||||
calls []recordingInviteCall
|
||||
}
|
||||
|
||||
type recordingInviteCall struct {
|
||||
refererID int64
|
||||
orderNo string
|
||||
}
|
||||
|
||||
func (h *recordingInviteHook) OnConversion(_ context.Context, refererID int64, orderNo string) {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
h.calls = append(h.calls, recordingInviteCall{refererID: refererID, orderNo: orderNo})
|
||||
}
|
||||
|
||||
// verify recordingInviteHook satisfies the interface at compile time.
|
||||
var _ lotteryhook.InviteHook = (*recordingInviteHook)(nil)
|
||||
|
||||
func (h *recordingInviteHook) snapshot() []recordingInviteCall {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
out := make([]recordingInviteCall, len(h.calls))
|
||||
copy(out, h.calls)
|
||||
return out
|
||||
}
|
||||
|
||||
func newLotteryHookTestLogic(hook lotteryhook.InviteHook) *ActivateOrderLogic {
|
||||
return NewActivateOrderLogic(&svc.ServiceContext{LotteryInviteHook: hook})
|
||||
}
|
||||
|
||||
// TestInvokeInviteHook_FiresOnFirstPurchase 保证新购激活会触发抽奖钩子。
|
||||
// Positive-path regression matched against the "首次付款激活" decision.
|
||||
func TestInvokeInviteHook_FiresOnFirstPurchase(t *testing.T) {
|
||||
hook := &recordingInviteHook{}
|
||||
logic := newLotteryHookTestLogic(hook)
|
||||
|
||||
logic.invokeInviteHookIfEligible(context.Background(), 42, &order.Order{
|
||||
OrderNo: "ORD-NEW-1",
|
||||
IsNew: true,
|
||||
})
|
||||
|
||||
calls := hook.snapshot()
|
||||
if len(calls) != 1 {
|
||||
t.Fatalf("expected exactly 1 OnConversion call on first purchase, got %+v", calls)
|
||||
}
|
||||
if calls[0].refererID != 42 || calls[0].orderNo != "ORD-NEW-1" {
|
||||
t.Fatalf("wrong args: %+v", calls[0])
|
||||
}
|
||||
}
|
||||
|
||||
// TestInvokeInviteHook_DoesNotFireOnRenewal 是架构师 R1 打回的关键回归:
|
||||
// 续费付款必须 NOT 触发抽奖钩子,否则 referer 每月拿一次白嫖机会,与"首次
|
||||
// 付款激活"决策相悖。
|
||||
func TestInvokeInviteHook_DoesNotFireOnRenewal(t *testing.T) {
|
||||
hook := &recordingInviteHook{}
|
||||
logic := newLotteryHookTestLogic(hook)
|
||||
|
||||
logic.invokeInviteHookIfEligible(context.Background(), 42, &order.Order{
|
||||
OrderNo: "ORD-RENEWAL-1",
|
||||
IsNew: false,
|
||||
})
|
||||
|
||||
if calls := hook.snapshot(); len(calls) != 0 {
|
||||
t.Fatalf("renewal must NOT trigger invite hook; got %+v", calls)
|
||||
}
|
||||
}
|
||||
|
||||
// TestInvokeInviteHook_NilOrderIsSafe 保证空 order 输入不 panic(防御性)。
|
||||
func TestInvokeInviteHook_NilOrderIsSafe(t *testing.T) {
|
||||
hook := &recordingInviteHook{}
|
||||
logic := newLotteryHookTestLogic(hook)
|
||||
logic.invokeInviteHookIfEligible(context.Background(), 42, nil)
|
||||
if calls := hook.snapshot(); len(calls) != 0 {
|
||||
t.Fatalf("nil order must be a no-op; got %+v", calls)
|
||||
}
|
||||
}
|
||||
|
||||
// TestInvokeInviteHook_NilHookIsSafe 保证 ServiceContext 里没接线时不 panic。
|
||||
func TestInvokeInviteHook_NilHookIsSafe(t *testing.T) {
|
||||
logic := NewActivateOrderLogic(&svc.ServiceContext{}) // no LotteryInviteHook
|
||||
logic.invokeInviteHookIfEligible(context.Background(), 42, &order.Order{
|
||||
OrderNo: "ORD-1",
|
||||
IsNew: true,
|
||||
})
|
||||
// If we got here without panicking, we pass.
|
||||
}
|
||||
Reference in New Issue
Block a user