新功能(#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:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user