a46fb83054
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 返回空。
138 lines
5.0 KiB
Go
138 lines
5.0 KiB
Go
// 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
|
|
}
|