新功能(#4): 抽奖 Stage 2 人工奖领奖工单(crypto / physical / manual_other)
Closes HIF-4 Stage 2 交付:人工奖领奖工单完整闭环。crypto / physical / manual_other 三类奖品从抽中到 mark-paid 的全流程可用。 - 迁移 02159_lottery_claim:UNIQUE(draw_id) + 3 支持索引,状态机 pending_claim→reviewing→paying→paid,rejected 可复活,超时 expired - 3 个 PrizeHandler:Dispatch→ErrDispatchNotSupported 兜底、ClaimSchema 各自形态、ValidateClaim 表驱动 - BuildCryptoClaimSchema:抽中时按奖品 config.networks 注入 enum,前端下拉直接可用 - Draw service dispatchOrEnqueueClaim:人工奖同 tx 插 pending_claim(回滚双清),nonce 重放回读 ExpiresAt + ClaimFormSchema - POST /claim 实装:ownership 校验 → prize 类型校验 → handler.ValidateClaim → crypto network 白名单二次校验 → tx CAS status IN (pending_claim, rejected) AND expires_at > now - Admin CRUD 5 接口:list(IN 批拉 snap + user,无 N+1)、summary(GROUP BY 一次拿计数 + overdue 单查)、approve/reject/mark-paid 全走 CAS + audit - Scheduler @every 1h 扫过期,级联 lottery_draw.dispatch_state → expired - 新增错误码 100005-100011(already_submitted / invalid_claim_data / draw_not_found / not_your_draw / claim_expired / claim_state_invalid) - Rebase 后 Stage 2 测试主动 reuse PR E 的 unmetReasonsNotEmpty + evaluatedAtNotZero matcher,人工奖分支若绕过守卫会立即挂 - Stage 1 全部 4 处 guardrail 后端 rebase 时自检过:UnmetReasons、EvaluatedAt、GrantLedger.Payload、AdminMetaMiddleware 全保留 CI 全绿;28 files, +2442/-126;覆盖率 handler 78.9% / model.lottery 74.3% / draw 68.7% / queue/lottery 76.9%
This commit is contained in:
@@ -4,22 +4,22 @@
|
||||
// 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
|
||||
// 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
|
||||
@@ -35,6 +35,7 @@ import (
|
||||
"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"
|
||||
@@ -72,6 +73,11 @@ 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
|
||||
@@ -82,13 +88,13 @@ type Service struct {
|
||||
|
||||
// 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
|
||||
DB *gorm.DB
|
||||
Enabled bool
|
||||
RateLimiter RateLimiter
|
||||
Chance lottery.ChanceService
|
||||
Evaluator lottery.RuleEvaluator
|
||||
Picker lottery.WeightedPicker
|
||||
Registry lottery.Registry
|
||||
ContextBuilder RuleContextBuilder
|
||||
}
|
||||
|
||||
@@ -200,8 +206,8 @@ func (s *Service) Draw(ctx context.Context, req Request) (*Result, error) {
|
||||
return wrapInternal(snapErr)
|
||||
}
|
||||
|
||||
// (f) Dispatch prize.
|
||||
dispatch, dispatchErr := s.dispatchIfAutomatic(ctx, tx, req, draw, final)
|
||||
// (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
|
||||
}
|
||||
@@ -211,7 +217,7 @@ func (s *Service) Draw(ctx context.Context, req Request) (*Result, error) {
|
||||
return wrapInternal(updateErr)
|
||||
}
|
||||
|
||||
result = buildResult(draw, final, dispatch, remaining)
|
||||
result = buildResult(draw, final, dispatch, claimInfo, remaining)
|
||||
return nil
|
||||
})
|
||||
if txErr != nil {
|
||||
@@ -395,32 +401,91 @@ func (s *Service) insertSnapshots(ctx context.Context, tx *gorm.DB, draw *lotter
|
||||
return tx.WithContext(ctx).Create(&es).Error
|
||||
}
|
||||
|
||||
func (s *Service) dispatchIfAutomatic(ctx context.Context, tx *gorm.DB, req Request, draw *lottery.Draw, prize lottery.Prize) (lottery.DispatchResult, 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: "谢谢参与"}, nil
|
||||
return lottery.DispatchResult{State: lottery.DispatchStateAutoClaimed, Message: "谢谢参与"}, pendingClaimInfo{}, nil
|
||||
}
|
||||
if s.deps.Registry == nil {
|
||||
return lottery.DispatchResult{State: lottery.DispatchStatePendingClaim, Message: "等待人工发放"}, nil
|
||||
return lottery.DispatchResult{State: lottery.DispatchStatePendingClaim, Message: "等待人工发放"}, pendingClaimInfo{}, nil
|
||||
}
|
||||
handler, err := s.deps.Registry.MustGet(prize.Type)
|
||||
prizeHandler, err := s.deps.Registry.MustGet(prize.Type)
|
||||
if err != nil {
|
||||
if errors.Is(err, lottery.ErrHandlerNotRegistered) {
|
||||
return lottery.DispatchResult{State: lottery.DispatchStatePendingClaim, Message: "等待人工发放"}, nil
|
||||
return lottery.DispatchResult{State: lottery.DispatchStatePendingClaim, Message: "等待人工发放"}, pendingClaimInfo{}, nil
|
||||
}
|
||||
return lottery.DispatchResult{}, wrapInternal(err)
|
||||
return lottery.DispatchResult{}, pendingClaimInfo{}, wrapInternal(err)
|
||||
}
|
||||
if !handler.IsAuto() {
|
||||
return lottery.DispatchResult{State: lottery.DispatchStatePendingClaim, Message: "等待人工发放"}, nil
|
||||
|
||||
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
|
||||
}
|
||||
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),
|
||||
|
||||
// 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,
|
||||
}
|
||||
return handler.Dispatch(ctx, tx, dispatchReq)
|
||||
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 {
|
||||
@@ -454,31 +519,53 @@ func (s *Service) buildResultFromExistingDraw(ctx context.Context, tx *gorm.DB,
|
||||
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: ClaimSummary{
|
||||
Required: existing.DispatchState == lottery.DispatchStatePendingClaim,
|
||||
AutoClaimed: existing.DispatchState == lottery.DispatchStateAutoClaimed,
|
||||
Message: "",
|
||||
},
|
||||
Claim: claim,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func buildResult(draw *lottery.Draw, prize lottery.Prize, dispatch lottery.DispatchResult, remaining int64) *Result {
|
||||
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,
|
||||
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,
|
||||
|
||||
Reference in New Issue
Block a user