新功能(#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
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
package hook
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/DATA-DOG/go-sqlmock"
|
||||
"github.com/perfect-panel/server/internal/model/lottery"
|
||||
"gorm.io/driver/mysql"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func newHookTestDB(t *testing.T) (*gorm.DB, sqlmock.Sqlmock, func()) {
|
||||
t.Helper()
|
||||
sqlDB, mock, err := sqlmock.New(sqlmock.QueryMatcherOption(sqlmock.QueryMatcherFunc(func(expected, actual string) error {
|
||||
if strings.Contains(actual, expected) {
|
||||
return nil
|
||||
}
|
||||
return errors.New("actual sql does not contain expected: " + expected)
|
||||
})))
|
||||
if err != nil {
|
||||
t.Fatalf("create sqlmock: %v", err)
|
||||
}
|
||||
db, err := gorm.Open(mysql.New(mysql.Config{Conn: sqlDB, SkipInitializeWithVersion: true}), &gorm.Config{SkipDefaultTransaction: true})
|
||||
if err != nil {
|
||||
_ = sqlDB.Close()
|
||||
t.Fatalf("open gorm db: %v", err)
|
||||
}
|
||||
return db, mock, func() { _ = sqlDB.Close() }
|
||||
}
|
||||
|
||||
type chanceCall struct {
|
||||
userId, activityId int64
|
||||
source, sourceRef string
|
||||
amount int
|
||||
}
|
||||
|
||||
type fakeChanceService struct {
|
||||
mu sync.Mutex
|
||||
calls []chanceCall
|
||||
err error
|
||||
}
|
||||
|
||||
func (f *fakeChanceService) Grant(_ context.Context, userId, activityId int64, source, sourceRef string, amount int) error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.calls = append(f.calls, chanceCall{userId, activityId, source, sourceRef, amount})
|
||||
return f.err
|
||||
}
|
||||
func (*fakeChanceService) Consume(context.Context, *gorm.DB, int64, int64) (int64, error) {
|
||||
return 0, nil
|
||||
}
|
||||
func (*fakeChanceService) Query(context.Context, int64, int64) (int64, error) { return 0, nil }
|
||||
|
||||
func (f *fakeChanceService) recorded() []chanceCall {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
out := make([]chanceCall, len(f.calls))
|
||||
copy(out, f.calls)
|
||||
return out
|
||||
}
|
||||
|
||||
func TestNoopInviteHook_IsInert(t *testing.T) {
|
||||
NoopInviteHook().OnConversion(context.Background(), 1, "ord")
|
||||
}
|
||||
|
||||
func TestInviteHook_SkipsWhenRefererMissing(t *testing.T) {
|
||||
chance := &fakeChanceService{}
|
||||
h := NewInviteHook(&gorm.DB{}, chance) // won't touch DB because refererUserID=0
|
||||
h.OnConversion(context.Background(), 0, "ord")
|
||||
// No goroutine means no calls; give scheduler a beat and confirm empty.
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
if len(chance.recorded()) != 0 {
|
||||
t.Fatalf("expected no Grant when refererUserID=0, got %+v", chance.recorded())
|
||||
}
|
||||
}
|
||||
|
||||
func TestInviteHook_GrantsForEachRunningActivity(t *testing.T) {
|
||||
db, mock, cleanup := newHookTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
// Activity 100: single invite_success source, amount=1
|
||||
// Activity 200: two sources, only invite_success (amount=2) counts
|
||||
// Activity 300: has invite_success amount=0 → skipped
|
||||
mock.ExpectQuery("FROM `lottery_activity`").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id", "chance_sources", "status", "start_at", "end_at", "eligibility", "grid_size", "unmet_action"}).
|
||||
AddRow(int64(100), `[{"source":"invite_success","amount":1}]`, "running", time.Now(), time.Now().Add(24*time.Hour), "{}", 9, "block").
|
||||
AddRow(int64(200), `[{"source":"daily_signin","amount":1},{"source":"invite_success","amount":2}]`, "running", time.Now(), time.Now().Add(24*time.Hour), "{}", 9, "block").
|
||||
AddRow(int64(300), `[{"source":"invite_success","amount":0}]`, "running", time.Now(), time.Now().Add(24*time.Hour), "{}", 9, "block"))
|
||||
|
||||
chance := &fakeChanceService{}
|
||||
h := NewInviteHook(db, chance)
|
||||
h.OnConversion(context.Background(), 42, "order-xyz")
|
||||
|
||||
// give the goroutine time to complete
|
||||
deadline := time.After(2 * time.Second)
|
||||
for len(chance.recorded()) < 2 {
|
||||
select {
|
||||
case <-deadline:
|
||||
t.Fatalf("timed out waiting for grants; got %+v", chance.recorded())
|
||||
case <-time.After(20 * time.Millisecond):
|
||||
}
|
||||
}
|
||||
|
||||
calls := chance.recorded()
|
||||
if len(calls) != 2 {
|
||||
t.Fatalf("expected 2 grants (100 amount=1, 200 amount=2), got %+v", calls)
|
||||
}
|
||||
byActivity := map[int64]int{}
|
||||
for _, c := range calls {
|
||||
if c.source != lottery.ChanceSourceInviteSuccess {
|
||||
t.Fatalf("unexpected source: %+v", c)
|
||||
}
|
||||
if c.sourceRef != "order-xyz" {
|
||||
t.Fatalf("expected orderNo reused as source_ref, got %q", c.sourceRef)
|
||||
}
|
||||
if c.userId != 42 {
|
||||
t.Fatalf("expected referer=42, got %d", c.userId)
|
||||
}
|
||||
byActivity[c.activityId] = c.amount
|
||||
}
|
||||
if byActivity[100] != 1 || byActivity[200] != 2 {
|
||||
t.Fatalf("wrong amounts: %+v", byActivity)
|
||||
}
|
||||
if _, exists := byActivity[300]; exists {
|
||||
t.Fatalf("activity 300 has invite_success amount=0 and must be skipped")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInviteHook_MalformedChanceSourcesSkipsOnly(t *testing.T) {
|
||||
db, mock, cleanup := newHookTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
mock.ExpectQuery("FROM `lottery_activity`").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id", "chance_sources", "status", "start_at", "end_at", "eligibility", "grid_size", "unmet_action"}).
|
||||
AddRow(int64(100), `[{"source":"invite_success","amount":3}]`, "running", time.Now(), time.Now().Add(24*time.Hour), "{}", 9, "block").
|
||||
AddRow(int64(200), `{bad-json`, "running", time.Now(), time.Now().Add(24*time.Hour), "{}", 9, "block"))
|
||||
|
||||
chance := &fakeChanceService{}
|
||||
h := NewInviteHook(db, chance)
|
||||
h.OnConversion(context.Background(), 42, "order-1")
|
||||
|
||||
deadline := time.After(2 * time.Second)
|
||||
for len(chance.recorded()) < 1 {
|
||||
select {
|
||||
case <-deadline:
|
||||
t.Fatalf("timed out; got %+v", chance.recorded())
|
||||
case <-time.After(20 * time.Millisecond):
|
||||
}
|
||||
}
|
||||
|
||||
// Only the well-formed activity should have been granted; malformed skipped silently.
|
||||
calls := chance.recorded()
|
||||
if len(calls) != 1 || calls[0].activityId != 100 {
|
||||
t.Fatalf("expected exactly 1 grant for activity 100, got %+v", calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInviteHook_QueryFailureLogsAndReturns(t *testing.T) {
|
||||
db, mock, cleanup := newHookTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
mock.ExpectQuery("FROM `lottery_activity`").
|
||||
WillReturnError(errors.New("db down"))
|
||||
|
||||
chance := &fakeChanceService{}
|
||||
h := NewInviteHook(db, chance)
|
||||
h.OnConversion(context.Background(), 42, "ord")
|
||||
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
if len(chance.recorded()) != 0 {
|
||||
t.Fatalf("expected no grants when query fails, got %+v", chance.recorded())
|
||||
}
|
||||
}
|
||||
|
||||
func TestInviteHook_ParseHelperExposesInviteAmountsOnly(t *testing.T) {
|
||||
got := parseInviteGrantsFromSources(`[{"source":"invite_success","amount":5},{"source":"daily_signin","amount":9},{"source":"invite_success","amount":0}]`)
|
||||
if len(got) != 1 || got[0] != 5 {
|
||||
t.Fatalf("expected [5], got %v", got)
|
||||
}
|
||||
if got := parseInviteGrantsFromSources(""); got != nil {
|
||||
t.Fatalf("empty string should return nil, got %v", got)
|
||||
}
|
||||
if got := parseInviteGrantsFromSources(`{bad`); got != nil {
|
||||
t.Fatalf("bad json should return nil, got %v", got)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user