Files
hi-server/internal/logic/lottery/hook/invite.go
T
shanshanzhong147 ce3babcc33 新功能(#3): 抽奖 Stage 1 收官 — 用户 API + 后台 CRUD + 集成
Closes HIF-3

Stage 1 完整闭环 PR C:用户 API + 后台 CRUD + 抽奖事务服务 + 审计 + QA curl。合并后 Stage 1 可交测试。

架构师 review R1(rulecaps depth≤8/nodes≤64/bytes≤8KB)+ R2(InviteHook source_ref 加 order: 前缀)已全部落地。

- 迁移 02158_admin_action_log:后台写操作审计
- xerr 100xxx 段:抽奖错误码(NotEligible/NoChances/ActivityEnded/RateLimited/NotClaimable/InternalError/RuleTooDeep/RuleTooMany/RuleTooLarge)
- feature flag config.Lottery.Enable 默认 false,合并后线上零副作用
- draw service:feature flag → rate limit → pre-tx reads → nonce dedupe → Consume → Pick → 乐观扣库存 → 双快照 → Dispatch → finalize
- 用户 API 4 个:GET /config、POST /draw、GET /records、POST /claim(Stage 1 返回 100010)
- 后台 CRUD:活动 / 奖品 / rules PUT(rulecaps gate)/ chances/grant
- audit.WriteAdminAction:与调用方 tx 同生共死,SHA1 body 摘要
- QA 脚本:qa/lottery/stage1_curl.sh 全链路 curl

测试覆盖:model 76.5% / draw 69% / handler 68.3% / hook 91.9% / rulecaps 87.8% / audit 100%
100 并发抢库存 + 10000 次概率分布 e2e 推 QA 环境(sqlmock 无法忠实模拟 InnoDB 行锁)
CI 全绿:构建/Vet/测试 + golangci-lint
2026-07-08 22:33:18 -07:00

141 lines
5.2 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).
// 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
}