新功能(#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:
2026-07-10 04:01:34 -07:00
committed by GitHub
parent 117dc0d6a7
commit 07409eb602
28 changed files with 2471 additions and 133 deletions
+138 -51
View File
@@ -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,