// 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). // Prefix orderNo with "order:" so audit trails can tell business-order // derived refs apart from other source families (manual_grant uses // "manual:*", daily_signin uses "signin:*"). DB uniqueness is already // bucketed by source, but the prefix makes log/analytics readable. ref := "order:" + orderNo if err := h.chance.Grant(ctx, refererUserID, activity.Id, lottery.ChanceSourceInviteSuccess, ref, 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 }