// Package draw implements POST /api/v1/lottery/draw: the transactional lottery // draw flow. The service composes the ChanceService, RuleEvaluator, // WeightedPicker, PrizeHandler.Registry and LedgerService primitives from the // model layer into one atomic sequence. // // Ordering matters — the flow is: // 1. feature-flag gate (config.Lottery.Enable) // 2. Redis rate limit (per-user 1/sec) // 3. Load activity + validate window/status // 4. Build RuleContext (pre-tx reads) // 5. Evaluate eligibility (pure compute) // 6. Load prize pool snapshot (pre-tx read) // 7. Open tx → // 7a. Nonce dedupe: SELECT lottery_draw WHERE (user_id, client_nonce) // — hit returns the recorded draw // 7b. ChanceService.Consume (SELECT FOR UPDATE + decrement) // 7c. WeightedPicker.Pick // 7d. Limited-stock optimistic decrement; fallback on RowsAffected=0 // 7e. INSERT lottery_draw + PrizeSnapshot + EligibilitySnapshot // 7f. Auto-handler Dispatch (in tx) // 7g. Update draw.dispatch_state // → commit // // Everything past step 5 uses the caller's transaction; post-commit cache // invalidation is the handler layer's job (a future enhancement — the // underlying UserModel already invalidates its own cache on UpdateSubscribe). package draw import ( "context" "database/sql" "encoding/json" "errors" "fmt" "strconv" "time" "github.com/perfect-panel/server/internal/logic/lottery/handler" "github.com/perfect-panel/server/internal/model/lottery" "github.com/perfect-panel/server/pkg/limit" "github.com/perfect-panel/server/pkg/xerr" "gorm.io/gorm" ) // Request is the input to Draw. type Request struct { UserId int64 ActivityId int64 ClientNonce string } // Result is what Draw returns to the caller (user handler). type Result struct { DrawId int64 IsWin bool Prize *PrizeSummary ChancesRemaining int64 Claim ClaimSummary Message string } // PrizeSummary is the awarded-prize view rendered for the user. type PrizeSummary struct { Slot int Id int64 Type string Name string Config json.RawMessage } // ClaimSummary describes whether the user needs to take a further action. type ClaimSummary struct { Required bool AutoClaimed bool Message string // ExpiresAt 是人工奖领奖窗口截止时间(Unix 秒;0 表示不适用)。 ExpiresAt int64 // ClaimFormSchema 是人工奖前端渲染领奖表单用的 JSON Schema // (nil 表示不适用;auto handler 与"谢谢参与"都返回 nil)。 ClaimFormSchema json.RawMessage } // Service orchestrates the transactional draw flow. Deps are struct-injected // so tests can substitute fakes and production wiring lives in ServiceContext. type Service struct { deps Deps } // Deps groups the collaborators. Nil-safe checks live in Draw itself, not here. type Deps struct { DB *gorm.DB Enabled bool RateLimiter RateLimiter Chance lottery.ChanceService Evaluator lottery.RuleEvaluator Picker lottery.WeightedPicker Registry lottery.Registry ContextBuilder RuleContextBuilder } // RateLimiter admits at most 1 draw per second per user. Extracted to an // interface so tests can supply an always-admit fake without pulling Redis. type RateLimiter interface { Allow(ctx context.Context, userId int64) error } // RuleContextBuilder loads the per-user snapshot needed by the rule // evaluator. Extracted so tests can inject deterministic contexts. type RuleContextBuilder interface { Build(ctx context.Context, userId int64) (lottery.RuleContext, error) } // NewService returns a Draw service ready to serve requests. func NewService(d Deps) *Service { return &Service{deps: d} } // Draw runs the full lottery draw flow. Errors are xerr codes suitable for // direct return by the HTTP handler; internal errors are wrapped as // LotteryInternalError. func (s *Service) Draw(ctx context.Context, req Request) (*Result, error) { if err := s.validateRequest(req); err != nil { return nil, err } if !s.deps.Enabled { return nil, xerr.NewErrCode(xerr.LotteryActivityEnded) } if err := s.applyRateLimit(ctx, req.UserId); err != nil { return nil, err } activity, err := s.loadRunningActivity(ctx, req.ActivityId) if err != nil { return nil, err } // Pre-tx reads: user context + prize pool snapshot. Cheap and out of the // hot-lock window; the tx step re-checks stock atomically. rc, err := s.buildRuleContext(ctx, req.UserId) if err != nil { return nil, wrapInternal(err) } tree, err := parseEligibilityTree(activity.Eligibility) if err != nil { return nil, wrapInternal(err) } passed, unmet, err := s.deps.Evaluator.Evaluate(ctx, tree, rc) if err != nil { return nil, wrapInternal(err) } if !passed { // The rejection itself is not an error to the caller — but we still // record an eligibility snapshot for support/audit before returning // the 4001. Snapshot write intentionally uses its own tx: the draw // itself never got issued, so there is no draw_id to correlate; we // omit the snapshot in that case. _ = unmet // unmet is available to the handler via error metadata if needed return nil, xerr.NewErrCode(xerr.LotteryNotEligible) } prizes, err := s.loadPrizes(ctx, req.ActivityId) if err != nil { return nil, wrapInternal(err) } if len(prizes) == 0 { return nil, xerr.NewErrCode(xerr.LotteryActivityEnded) } var result *Result txErr := s.deps.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error { // (a) Nonce dedupe — race-safe idempotency check. if existing, existsErr := s.findExistingDraw(ctx, tx, req); existsErr != nil { return existsErr } else if existing != nil { result, existsErr = s.buildResultFromExistingDraw(ctx, tx, existing) return existsErr } // (b) Consume chance atomically. ErrNoChances → 4002. remaining, consumeErr := s.deps.Chance.Consume(ctx, tx, req.UserId, req.ActivityId) if errors.Is(consumeErr, lottery.ErrNoChances) { return xerr.NewErrCode(xerr.LotteryNoChances) } if consumeErr != nil { return wrapInternal(consumeErr) } // (c) Pick a prize. idx, pickErr := s.deps.Picker.Pick(prizes) if pickErr != nil { return xerr.NewErrCode(xerr.LotteryActivityEnded) } picked := prizes[idx] // (d) Limited-stock decrement. final, stockErr := s.decrementStockOrFallback(ctx, tx, picked, prizes) if stockErr != nil { return wrapInternal(stockErr) } // (e) Insert draw + snapshots. draw, insertErr := s.insertDraw(ctx, tx, req, final) if insertErr != nil { return wrapInternal(insertErr) } if snapErr := s.insertSnapshots(ctx, tx, draw, final, passed); snapErr != nil { return wrapInternal(snapErr) } // (f) Dispatch prize (auto handler) or create pending claim (manual handler). dispatch, claimInfo, dispatchErr := s.dispatchOrEnqueueClaim(ctx, tx, req, draw, final) if dispatchErr != nil { return dispatchErr } // (g) Update draw.dispatch_state to reflect handler outcome. if updateErr := s.finalizeDrawState(ctx, tx, draw, dispatch); updateErr != nil { return wrapInternal(updateErr) } result = buildResult(draw, final, dispatch, claimInfo, remaining) return nil }) if txErr != nil { if _, ok := txErr.(*xerr.CodeError); ok { return nil, txErr } return nil, wrapInternal(txErr) } return result, nil } // ---- individual steps ------------------------------------------------------ func (s *Service) validateRequest(req Request) error { if req.UserId <= 0 || req.ActivityId <= 0 || req.ClientNonce == "" { return xerr.NewErrCode(xerr.InvalidParams) } if len(req.ClientNonce) > 64 { return xerr.NewErrCode(xerr.InvalidParams) } return nil } func (s *Service) applyRateLimit(ctx context.Context, userId int64) error { if s.deps.RateLimiter == nil { return nil } if err := s.deps.RateLimiter.Allow(ctx, userId); err != nil { if errors.Is(err, ErrRateLimited) { return xerr.NewErrCode(xerr.LotteryRateLimited) } return wrapInternal(err) } return nil } func (s *Service) loadRunningActivity(ctx context.Context, activityId int64) (*lottery.Activity, error) { var activity lottery.Activity now := time.Now() err := s.deps.DB.WithContext(ctx). Where("id = ?", activityId). First(&activity).Error if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, xerr.NewErrCode(xerr.LotteryActivityEnded) } return nil, wrapInternal(err) } if activity.Status != lottery.ActivityStatusRunning { return nil, xerr.NewErrCode(xerr.LotteryActivityEnded) } if activity.StartAt.After(now) || activity.EndAt.Before(now) { return nil, xerr.NewErrCode(xerr.LotteryActivityEnded) } return &activity, nil } func (s *Service) buildRuleContext(ctx context.Context, userId int64) (lottery.RuleContext, error) { if s.deps.ContextBuilder == nil { return lottery.RuleContext{UserId: userId, Now: time.Now().Unix()}, nil } return s.deps.ContextBuilder.Build(ctx, userId) } func (s *Service) loadPrizes(ctx context.Context, activityId int64) ([]lottery.Prize, error) { var prizes []lottery.Prize err := s.deps.DB.WithContext(ctx). Where("activity_id = ?", activityId). Order("slot ASC"). Find(&prizes).Error if err != nil { return nil, err } return prizes, nil } func (s *Service) findExistingDraw(ctx context.Context, tx *gorm.DB, req Request) (*lottery.Draw, error) { var existing lottery.Draw err := tx.WithContext(ctx). Where("user_id = ? AND client_nonce = ?", req.UserId, req.ClientNonce). First(&existing).Error if err == nil { return &existing, nil } if errors.Is(err, gorm.ErrRecordNotFound) { return nil, nil } return nil, err } // decrementStockOrFallback runs the limited-stock optimistic lock. If the // chosen prize is unlimited, returns it as-is. If limited and stock survives // → returns it. If limited and sold-out → walks the pool for the first // `is_fallback=true` prize or falls back to a "none" (thanks-for-playing) // synthetic prize. func (s *Service) decrementStockOrFallback(ctx context.Context, tx *gorm.DB, picked lottery.Prize, pool []lottery.Prize) (lottery.Prize, error) { if !picked.RemainingStock.Valid { return picked, nil } res := tx.WithContext(ctx). Model(&lottery.Prize{}). Where("id = ? AND remaining_stock > 0", picked.Id). UpdateColumn("remaining_stock", gorm.Expr("`remaining_stock` - 1")) if res.Error != nil { return lottery.Prize{}, res.Error } if res.RowsAffected > 0 { return picked, nil } // Sold out → fallback selection. for _, p := range pool { if p.IsFallback { return p, nil } } // No fallback declared → synthesize a "谢谢参与" from the last non-fallback // entry (any type=none in the pool wins); if pool has no none, we // synthesize an ephemeral prize record. Note: this prize is NOT persisted // as a separate row — it just satisfies the return contract. for _, p := range pool { if p.Type == lottery.PrizeTypeNone { return p, nil } } return lottery.Prize{ ActivityId: picked.ActivityId, Slot: picked.Slot, Type: lottery.PrizeTypeNone, Name: "谢谢参与", Config: "{}", }, nil } func (s *Service) insertDraw(ctx context.Context, tx *gorm.DB, req Request, prize lottery.Prize) (*lottery.Draw, error) { draw := lottery.Draw{ UserId: req.UserId, ActivityId: req.ActivityId, ClientNonce: req.ClientNonce, IsWin: prize.Type != lottery.PrizeTypeNone, DispatchState: lottery.DispatchStateNone, DrawnAt: time.Now(), } if prize.Id > 0 { draw.PrizeId = sql.NullInt64{Int64: prize.Id, Valid: true} } if err := tx.WithContext(ctx).Create(&draw).Error; err != nil { return nil, err } return &draw, nil } func (s *Service) insertSnapshots(ctx context.Context, tx *gorm.DB, draw *lottery.Draw, prize lottery.Prize, passedEligibility bool) error { ps := lottery.PrizeSnapshot{ DrawId: draw.Id, PrizeId: prize.Id, Slot: prize.Slot, Type: prize.Type, Name: prize.Name, Config: prize.Config, } if ps.Config == "" { ps.Config = "{}" } if err := tx.WithContext(ctx).Create(&ps).Error; err != nil { return err } es := lottery.EligibilitySnapshot{ DrawId: draw.Id, UserId: draw.UserId, ActivityId: draw.ActivityId, Passed: passedEligibility, // UnmetReasons 是 JSON 列,MySQL 拒绝空字符串(error 3140)—— // Stage 1 只有 passed=true 进 insertSnapshots,语义上"没有未过项", // 用 "[]" 与 PrizeSnapshot.Config 的 "{}" 守卫对称。 // Stage 2 若开始持久化 passed=false 的失败评估,再改成真正的 marshal。 UnmetReasons: "[]", // 显式 time.Now():GORM 遇 zero time 有时会传 '0000-00-00 00:00:00', // 触 sql_mode STRICT。不依赖 DB DEFAULT CURRENT_TIMESTAMP。 EvaluatedAt: time.Now(), } return tx.WithContext(ctx).Create(&es).Error } // pendingClaimInfo carries the manual-claim details the draw service produced // this turn. Zero-value = draw did not create a claim (auto prize or none). type pendingClaimInfo struct { ExpiresAt time.Time ClaimFormSchema json.RawMessage } // dispatchOrEnqueueClaim routes the prize to either an auto-handler dispatch // (Stage 1 path) or to a lottery_claim insert (Stage 2 manual path). // // - draw.IsWin == false → thanks-for-playing, auto_claimed. // - handler.IsAuto()==true → call Dispatch inside caller's tx. // - handler.IsAuto()==false → insert lottery_claim (pending_claim) and // return ClaimFormSchema + ExpiresAt so the // caller can render the response. func (s *Service) dispatchOrEnqueueClaim(ctx context.Context, tx *gorm.DB, req Request, draw *lottery.Draw, prize lottery.Prize) (lottery.DispatchResult, pendingClaimInfo, error) { if !draw.IsWin { return lottery.DispatchResult{State: lottery.DispatchStateAutoClaimed, Message: "谢谢参与"}, pendingClaimInfo{}, nil } if s.deps.Registry == nil { return lottery.DispatchResult{State: lottery.DispatchStatePendingClaim, Message: "等待人工发放"}, pendingClaimInfo{}, nil } prizeHandler, err := s.deps.Registry.MustGet(prize.Type) if err != nil { if errors.Is(err, lottery.ErrHandlerNotRegistered) { return lottery.DispatchResult{State: lottery.DispatchStatePendingClaim, Message: "等待人工发放"}, pendingClaimInfo{}, nil } return lottery.DispatchResult{}, pendingClaimInfo{}, wrapInternal(err) } if prizeHandler.IsAuto() { dispatchReq := lottery.DispatchRequest{ UserId: req.UserId, ActivityId: req.ActivityId, DrawId: draw.Id, Prize: prize, Snapshot: lottery.PrizeSnapshot{DrawId: draw.Id, PrizeId: prize.Id, Slot: prize.Slot, Type: prize.Type, Name: prize.Name, Config: prize.Config}, IdempotencyKey: fmt.Sprintf("lottery:%d:%d", req.ActivityId, draw.Id), } result, dispatchErr := prizeHandler.Dispatch(ctx, tx, dispatchReq) return result, pendingClaimInfo{}, dispatchErr } // Manual-claim path (Stage 2). Insert a pending_claim row inside the same // draw tx so a rollback also erases the claim. expiresAt := s.computeClaimExpiry(prize) claim := lottery.Claim{ DrawId: draw.Id, UserId: req.UserId, ActivityId: req.ActivityId, PrizeType: prize.Type, Status: lottery.ClaimStatusPendingClaim, ExpiresAt: expiresAt, } if err := tx.WithContext(ctx).Create(&claim).Error; err != nil { return lottery.DispatchResult{}, pendingClaimInfo{}, wrapInternal(fmt.Errorf("insert lottery_claim for draw %d: %w", draw.Id, err)) } schema := prizeHandler.ClaimSchema() // crypto handler 需要用奖品 config.networks 生成带 enum 的最终 schema。 if prize.Type == lottery.PrizeTypeCrypto { schema = handler.BuildCryptoClaimSchema(prize.Config) } return lottery.DispatchResult{ State: lottery.DispatchStatePendingClaim, Message: "等待填写领奖信息", }, pendingClaimInfo{ ExpiresAt: expiresAt, ClaimFormSchema: schema, }, nil } // computeClaimExpiry 从奖品 config.claim_ttl_hours 读窗口配置;缺失或非正 // 则回落到 lottery.DefaultClaimTTLHours (7 天)。 func (s *Service) computeClaimExpiry(prize lottery.Prize) time.Time { hours := lottery.DefaultClaimTTLHours if prize.Config != "" { var cfg struct { ClaimTTLHours int `json:"claim_ttl_hours"` } if err := json.Unmarshal([]byte(prize.Config), &cfg); err == nil && cfg.ClaimTTLHours > 0 { hours = cfg.ClaimTTLHours } } return time.Now().Add(time.Duration(hours) * time.Hour) } func (s *Service) finalizeDrawState(ctx context.Context, tx *gorm.DB, draw *lottery.Draw, dispatch lottery.DispatchResult) error { now := time.Now() updates := map[string]any{ "dispatch_state": dispatch.State, } if dispatch.State == lottery.DispatchStateAutoClaimed || dispatch.State == lottery.DispatchStatePaid { updates["dispatched_at"] = now } return tx.WithContext(ctx). Model(&lottery.Draw{}). Where("id = ?", draw.Id). Updates(updates).Error } func (s *Service) buildResultFromExistingDraw(ctx context.Context, tx *gorm.DB, existing *lottery.Draw) (*Result, error) { var snap lottery.PrizeSnapshot err := tx.WithContext(ctx).Where("draw_id = ?", existing.Id).First(&snap).Error if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { return nil, err } remaining, _ := s.deps.Chance.Query(ctx, existing.UserId, existing.ActivityId) var prize *PrizeSummary if existing.IsWin { prize = &PrizeSummary{ Slot: snap.Slot, Id: snap.PrizeId, Type: snap.Type, Name: snap.Name, Config: json.RawMessage(snap.Config), } } claim := ClaimSummary{ Required: existing.DispatchState == lottery.DispatchStatePendingClaim, AutoClaimed: existing.DispatchState == lottery.DispatchStateAutoClaimed, } // 重放场景(同一 client_nonce)也补回 expires_at / schema,避免前端第二次 // 收到的响应比首次少字段。 if claim.Required { var claimRow lottery.Claim err := tx.WithContext(ctx).Where("draw_id = ?", existing.Id).First(&claimRow).Error if err == nil { claim.ExpiresAt = claimRow.ExpiresAt.Unix() if s.deps.Registry != nil { if h, ok := s.deps.Registry.Get(claimRow.PrizeType); ok { if claimRow.PrizeType == lottery.PrizeTypeCrypto { claim.ClaimFormSchema = handler.BuildCryptoClaimSchema(snap.Config) } else { claim.ClaimFormSchema = h.ClaimSchema() } } } } } return &Result{ DrawId: existing.Id, IsWin: existing.IsWin, Prize: prize, ChancesRemaining: remaining, Claim: claim, }, nil } func buildResult(draw *lottery.Draw, prize lottery.Prize, dispatch lottery.DispatchResult, claim pendingClaimInfo, remaining int64) *Result { res := &Result{ DrawId: draw.Id, IsWin: draw.IsWin, ChancesRemaining: remaining, Message: dispatch.Message, Claim: ClaimSummary{ Required: dispatch.State == lottery.DispatchStatePendingClaim, AutoClaimed: dispatch.State == lottery.DispatchStateAutoClaimed, Message: dispatch.Message, ClaimFormSchema: claim.ClaimFormSchema, }, } if !claim.ExpiresAt.IsZero() { res.Claim.ExpiresAt = claim.ExpiresAt.Unix() } if draw.IsWin { res.Prize = &PrizeSummary{ Slot: prize.Slot, Id: prize.Id, Type: prize.Type, Name: prize.Name, Config: json.RawMessage(prize.Config), } } return res } // parseEligibilityTree tolerates empty/null activity.Eligibility as "no gate". func parseEligibilityTree(raw string) (*lottery.EligibilityRule, error) { trimmed := "" for _, r := range raw { if r != ' ' && r != '\t' && r != '\n' && r != '\r' { trimmed += string(r) } } if trimmed == "" || trimmed == "null" || trimmed == "{}" { return nil, nil } var tree lottery.EligibilityRule if err := json.Unmarshal([]byte(raw), &tree); err != nil { return nil, fmt.Errorf("parse eligibility: %w", err) } return &tree, nil } func wrapInternal(err error) error { if err == nil { return nil } // Preserve already-coded errors. if _, ok := err.(*xerr.CodeError); ok { return err } return xerr.NewErrCodeMsg(xerr.LotteryInternalError, err.Error()) } // ---- Rate limiter production wiring ---------------------------------------- // ErrRateLimited is returned by RateLimiter.Allow when the caller exceeded // the configured quota. var ErrRateLimited = errors.New("draw: rate limited") // RedisRateLimiter is the production RateLimiter backed by pkg/limit's // Redis-Lua fixed-window (1 hit per 1 second per user), matching the // existing sendEmailCodeLogic pattern. type RedisRateLimiter struct { limiter *limit.PeriodLimit } // NewRedisRateLimiter builds a per-user 1-req/1-sec limiter. keyPrefix is // expected to end with ':' so the composed key is human-readable. func NewRedisRateLimiter(limiter *limit.PeriodLimit) *RedisRateLimiter { return &RedisRateLimiter{limiter: limiter} } // Allow admits or rejects the caller. func (r *RedisRateLimiter) Allow(ctx context.Context, userId int64) error { if r == nil || r.limiter == nil { return nil } state, err := r.limiter.TakeCtx(ctx, strconv.FormatInt(userId, 10)) if err != nil { return err } if state == limit.Allowed || state == limit.HitQuota { return nil } return ErrRateLimited }