新功能(#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:
2026-07-08 21:19:28 -07:00
committed by GitHub
parent 9933d34bdd
commit a46fb83054
18 changed files with 1585 additions and 61 deletions
+26
View File
@@ -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 {