Files
hi-server/queue/logic/order/invite_hook_gate_test.go
shanshanzhong147 a46fb83054 新功能(#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 返回空。
2026-07-08 21:19:28 -07:00

101 lines
3.2 KiB
Go

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