新功能(#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
+137
View File
@@ -0,0 +1,137 @@
// Package hook contains lottery-side outbound integrations — hooks other flows
// (order activation, sign-in, etc.) call after they succeed to feed events into
// the lottery system.
//
// All hooks are fire-and-forget by contract: they run in their own goroutine so
// caller latency and error handling are unaffected. Hook failures are logged
// and dropped — an invite that fails to earn a lottery chance never blocks the
// order it was piggy-backing on.
package hook
import (
"context"
"encoding/json"
"time"
"github.com/perfect-panel/server/internal/model/lottery"
"github.com/perfect-panel/server/pkg/logger"
"gorm.io/gorm"
)
// InviteHook is fired by order/renewal activation when an invited user
// completes a payment. It grants lottery chances to the referer across every
// currently running activity that declares an "invite_success" chance source.
type InviteHook interface {
// OnConversion queues a background grant for referer. Returns immediately.
// Safe to call with refererUserID=0 (no-op) or orderNo="" (no-op).
OnConversion(ctx context.Context, refererUserID int64, orderNo string)
}
// NoopInviteHook is a safe placeholder for callers that need an InviteHook
// value before the lottery system is wired in. Its OnConversion returns
// immediately without side effects — no goroutine, no log spam.
func NoopInviteHook() InviteHook { return noopInviteHook{} }
type noopInviteHook struct{}
func (noopInviteHook) OnConversion(_ context.Context, _ int64, _ string) {}
// defaultInviteHook is the production implementation. It queries running
// activities on every call rather than caching them — the query is cheap
// (small table, indexed by status+time), and skipping the cache avoids stale
// reads when an activity is paused or its chance_sources are re-configured.
type defaultInviteHook struct {
db *gorm.DB
chance lottery.ChanceService
}
// NewInviteHook builds the production invite hook.
func NewInviteHook(db *gorm.DB, chance lottery.ChanceService) InviteHook {
if db == nil || chance == nil {
return NoopInviteHook()
}
return &defaultInviteHook{db: db, chance: chance}
}
// OnConversion spawns a fire-and-forget goroutine that walks all running
// activities and calls ChanceService.Grant for each one that declares an
// invite_success source.
func (h *defaultInviteHook) OnConversion(_ context.Context, refererUserID int64, orderNo string) {
if refererUserID <= 0 || orderNo == "" {
return
}
go h.run(refererUserID, orderNo)
}
func (h *defaultInviteHook) run(refererUserID int64, orderNo string) {
// Fresh context so the caller cancelling their goroutine does not abort us.
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
activities, err := h.loadRunningActivities(ctx)
if err != nil {
logger.WithContext(ctx).Error("[lottery invite hook] load running activities failed",
logger.Field("error", err.Error()),
logger.Field("referer_user_id", refererUserID),
logger.Field("order_no", orderNo),
)
return
}
for i := range activities {
activity := &activities[i]
grants := parseInviteGrantsFromSources(activity.ChanceSources)
for _, amount := range grants {
if amount <= 0 {
continue
}
// ChanceService.Grant is idempotent per (activity_id, source, source_ref).
// orderNo as source_ref makes both new-purchase and renewal safe: same
// order → same key → at most one chance grant per (activity, order).
if err := h.chance.Grant(ctx, refererUserID, activity.Id, lottery.ChanceSourceInviteSuccess, orderNo, amount); err != nil {
logger.WithContext(ctx).Error("[lottery invite hook] Grant failed",
logger.Field("error", err.Error()),
logger.Field("referer_user_id", refererUserID),
logger.Field("activity_id", activity.Id),
logger.Field("order_no", orderNo),
)
}
}
}
}
func (h *defaultInviteHook) loadRunningActivities(ctx context.Context) ([]lottery.Activity, error) {
now := time.Now()
var activities []lottery.Activity
if err := h.db.WithContext(ctx).
Model(&lottery.Activity{}).
Where("status = ?", lottery.ActivityStatusRunning).
Where("start_at <= ? AND end_at >= ?", now, now).
Find(&activities).Error; err != nil {
return nil, err
}
return activities, nil
}
// parseInviteGrantsFromSources decodes the JSON chance_sources array on an
// activity and returns the per-conversion grant amount for each invite_success
// source (an activity may declare multiple, e.g. with different params by
// referer tier — v1 does not, but the loop is a cheap forward-compatibility).
func parseInviteGrantsFromSources(raw string) []int {
if raw == "" {
return nil
}
var sources []lottery.ChanceSource
if err := json.Unmarshal([]byte(raw), &sources); err != nil {
// Malformed configs skip silently — the activity is misconfigured, not
// a hook fault. Admin CRUD (PR C) will surface it.
return nil
}
out := make([]int, 0, len(sources))
for _, s := range sources {
if s.Source == lottery.ChanceSourceInviteSuccess && s.Amount > 0 {
out = append(out, s.Amount)
}
}
return out
}