5ef3f2717e
- 免费时长: 用户消息改为「稍后您的 N 天免费时长将会自动添加至您的账户。 如果超过24小时未添加成功,请联系人工客服处理。」(N=中奖天数动态) ledger.payload.message 仍保留descriptive内部文案供后台对账 - 人工发放(crypto/manual): 消息改为「请凭此截图直接联系人工客服兑换奖励。」 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
658 lines
22 KiB
Go
658 lines
22 KiB
Go
// 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/logger"
|
||
"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,
|
||
// ClaimData 是 JSON 列,MySQL 拒绝空字符串(error 3140)——用户填领奖
|
||
// 表单前用 "{}" 兜底,用户 POST /claim 会覆盖真实数据。与
|
||
// PrizeSnapshot.Config、EligibilitySnapshot.UnmetReasons、
|
||
// GrantLedger.Payload 的守卫风格一致。
|
||
ClaimData: "{}",
|
||
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
|
||
}
|
||
|
||
// wrapInternal 把内部 error 转成对外的 LotteryInternalError code。
|
||
//
|
||
// HIF-4 F10:msg 字段只带通用文案("抽奖服务暂时不可用"),err.Error() 的原文
|
||
// 只写日志,绝不外泄给 app 端。之前把 err.Error() 直接塞 msg 导致
|
||
// {"code":100500,"msg":"insert lottery_claim for draw 15: Error 3140 ..."} 这种
|
||
// 响应,泄露 DB 结构 + 撑爆前端 msg 字段。
|
||
func wrapInternal(err error) error {
|
||
if err == nil {
|
||
return nil
|
||
}
|
||
// Preserve already-coded errors (their msg 是设计过的对外文案,不动).
|
||
if _, ok := err.(*xerr.CodeError); ok {
|
||
return err
|
||
}
|
||
// 内部细节走日志,供运维/后端排查;err.Error() 不外传。
|
||
logger.WithContext(context.Background()).Error("[lottery draw internal error]",
|
||
logger.Field("error", err.Error()))
|
||
return xerr.NewErrCode(xerr.LotteryInternalError)
|
||
}
|
||
|
||
// ---- 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
|
||
}
|