新功能(#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,6 +4,7 @@ import (
|
||||
"github.com/hibiken/asynq"
|
||||
"github.com/perfect-panel/server/internal/svc"
|
||||
iapLogic "github.com/perfect-panel/server/queue/logic/iap"
|
||||
lotteryLogic "github.com/perfect-panel/server/queue/logic/lottery"
|
||||
orderLogic "github.com/perfect-panel/server/queue/logic/order"
|
||||
smslogic "github.com/perfect-panel/server/queue/logic/sms"
|
||||
"github.com/perfect-panel/server/queue/logic/subscription"
|
||||
@@ -51,4 +52,7 @@ func RegisterHandlers(mux *asynq.ServeMux, serverCtx *svc.ServiceContext) {
|
||||
|
||||
// Stuck order recovery
|
||||
mux.Handle(types.SchedulerStuckOrderRecovery, orderLogic.NewStuckOrderRecoveryLogic(serverCtx))
|
||||
|
||||
// Lottery Stage 2: 过期领奖工单每小时扫描一次
|
||||
mux.Handle(types.SchedulerLotteryExpireClaim, lotteryLogic.NewExpireClaimLogic(serverCtx))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
// Package lotteryLogic 承载抽奖相关的 asynq 后台任务。
|
||||
// Stage 2 唯一一个后台任务:每小时扫描 pending_claim 且已过期的领奖工单,
|
||||
// 状态推到 expired。业务规则:"过期不补次数",所以只更新 claim/draw,不做补偿。
|
||||
package lotteryLogic
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/hibiken/asynq"
|
||||
modelLottery "github.com/perfect-panel/server/internal/model/lottery"
|
||||
"github.com/perfect-panel/server/internal/svc"
|
||||
"github.com/perfect-panel/server/pkg/logger"
|
||||
)
|
||||
|
||||
// ExpireClaimLogic 扫描已过期但状态仍是 pending_claim 的 lottery_claim
|
||||
// (用户没在窗口内提交领奖信息),把它们推到 expired 终态。同时把关联的
|
||||
// lottery_draw.dispatch_state 也推到 expired,让 GET /records 与后台视图一致。
|
||||
type ExpireClaimLogic struct {
|
||||
svc *svc.ServiceContext
|
||||
}
|
||||
|
||||
func NewExpireClaimLogic(svc *svc.ServiceContext) *ExpireClaimLogic {
|
||||
return &ExpireClaimLogic{svc: svc}
|
||||
}
|
||||
|
||||
// ProcessTask 每小时被 asynq scheduler 触发。
|
||||
//
|
||||
// SQL 采用两步:
|
||||
// 1. UPDATE lottery_claim SET status='expired' WHERE status='pending_claim' AND expires_at < now
|
||||
// 2. UPDATE lottery_draw SET dispatch_state='expired'
|
||||
// WHERE dispatch_state='pending_claim'
|
||||
// AND id IN (SELECT draw_id FROM lottery_claim WHERE status='expired' AND ...)
|
||||
//
|
||||
// 步骤 1 由 lottery_claim.status 的语义主导;步骤 2 是为了避免 draw 视图脱节。
|
||||
// 单次 batch 不设上限——线上 pending_claim 过期量级远小于 asynq 单任务的处理窗口,
|
||||
// 若未来量级上来再改成 loop + LIMIT。
|
||||
func (l *ExpireClaimLogic) ProcessTask(ctx context.Context, _ *asynq.Task) error {
|
||||
now := time.Now()
|
||||
claimRes := l.svc.DB.WithContext(ctx).
|
||||
Model(&modelLottery.Claim{}).
|
||||
Where("status = ? AND expires_at < ?", modelLottery.ClaimStatusPendingClaim, now).
|
||||
Update("status", modelLottery.ClaimStatusExpired)
|
||||
if claimRes.Error != nil {
|
||||
logger.WithContext(ctx).Error("[LotteryExpireClaim] update lottery_claim failed",
|
||||
logger.Field("error", claimRes.Error.Error()),
|
||||
)
|
||||
return claimRes.Error
|
||||
}
|
||||
if claimRes.RowsAffected == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
drawRes := l.svc.DB.WithContext(ctx).
|
||||
Model(&modelLottery.Draw{}).
|
||||
Where("dispatch_state = ? AND id IN (?)",
|
||||
modelLottery.DispatchStatePendingClaim,
|
||||
l.svc.DB.WithContext(ctx).
|
||||
Model(&modelLottery.Claim{}).
|
||||
Select("draw_id").
|
||||
Where("status = ?", modelLottery.ClaimStatusExpired)).
|
||||
Update("dispatch_state", modelLottery.DispatchStateExpired)
|
||||
if drawRes.Error != nil {
|
||||
// draw 更新失败不阻断——status 已 expired,缺 draw 一致性下次跑还能补上。
|
||||
logger.WithContext(ctx).Error("[LotteryExpireClaim] update lottery_draw failed",
|
||||
logger.Field("error", drawRes.Error.Error()),
|
||||
logger.Field("claim_expired_count", claimRes.RowsAffected),
|
||||
)
|
||||
}
|
||||
logger.WithContext(ctx).Info("[LotteryExpireClaim] expired claims swept",
|
||||
logger.Field("claim_expired_count", claimRes.RowsAffected),
|
||||
logger.Field("draw_updated_count", drawRes.RowsAffected),
|
||||
)
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
// expireClaimLogic_test.go — 用 sqlmock 断言 SQL 契约(不涉及真实 DB)。
|
||||
package lotteryLogic
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DATA-DOG/go-sqlmock"
|
||||
"github.com/hibiken/asynq"
|
||||
"github.com/perfect-panel/server/internal/svc"
|
||||
"gorm.io/driver/mysql"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func newTestDB(t *testing.T) (*gorm.DB, sqlmock.Sqlmock, func()) {
|
||||
t.Helper()
|
||||
sqlDB, mock, err := sqlmock.New(sqlmock.QueryMatcherOption(sqlmock.QueryMatcherFunc(func(expected, actual string) error {
|
||||
if strings.Contains(actual, expected) {
|
||||
return nil
|
||||
}
|
||||
return errors.New("actual sql does not contain expected: " + expected)
|
||||
})))
|
||||
if err != nil {
|
||||
t.Fatalf("sqlmock: %v", err)
|
||||
}
|
||||
db, err := gorm.Open(mysql.New(mysql.Config{Conn: sqlDB, SkipInitializeWithVersion: true}), &gorm.Config{SkipDefaultTransaction: true})
|
||||
if err != nil {
|
||||
_ = sqlDB.Close()
|
||||
t.Fatalf("gorm: %v", err)
|
||||
}
|
||||
return db, mock, func() { _ = sqlDB.Close() }
|
||||
}
|
||||
|
||||
func TestExpireClaimLogic_NoRowsAffectedSkipsSecondUpdate(t *testing.T) {
|
||||
db, mock, cleanup := newTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
// UPDATE lottery_claim 但 RowsAffected == 0 → 应直接返回,不再打 UPDATE lottery_draw
|
||||
mock.ExpectExec("UPDATE `lottery_claim`").
|
||||
WillReturnResult(sqlmock.NewResult(0, 0))
|
||||
|
||||
logic := NewExpireClaimLogic(&svc.ServiceContext{DB: db})
|
||||
if err := logic.ProcessTask(context.Background(), asynq.NewTask("", nil)); err != nil {
|
||||
t.Fatalf("ProcessTask: %v", err)
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatalf("unmet expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExpireClaimLogic_ExpiresAndCascadesDraw(t *testing.T) {
|
||||
db, mock, cleanup := newTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
mock.ExpectExec("UPDATE `lottery_claim`").
|
||||
WillReturnResult(sqlmock.NewResult(0, 3))
|
||||
mock.ExpectExec("UPDATE `lottery_draw`").
|
||||
WillReturnResult(sqlmock.NewResult(0, 3))
|
||||
|
||||
logic := NewExpireClaimLogic(&svc.ServiceContext{DB: db})
|
||||
if err := logic.ProcessTask(context.Background(), asynq.NewTask("", nil)); err != nil {
|
||||
t.Fatalf("ProcessTask: %v", err)
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatalf("unmet expectations: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,8 @@ const (
|
||||
SchedulerTotalServerData = "scheduler:total:server"
|
||||
SchedulerResetTraffic = "scheduler:reset:traffic"
|
||||
SchedulerTrafficStat = "scheduler:traffic:stat"
|
||||
SchedulerIAPReconcile = "scheduler:iap:reconcile" // 第二层:每 5 分钟扫描待支付 IAP 订单
|
||||
SchedulerIAPDailyReconcile = "scheduler:iap:daily:reconcile" // 第三层:日终全量对账
|
||||
SchedulerStuckOrderRecovery = "scheduler:stuck:order:recovery" // 扫描并恢复超时 claimed 订单
|
||||
SchedulerIAPReconcile = "scheduler:iap:reconcile" // 第二层:每 5 分钟扫描待支付 IAP 订单
|
||||
SchedulerIAPDailyReconcile = "scheduler:iap:daily:reconcile" // 第三层:日终全量对账
|
||||
SchedulerStuckOrderRecovery = "scheduler:stuck:order:recovery" // 扫描并恢复超时 claimed 订单
|
||||
SchedulerLotteryExpireClaim = "scheduler:lottery:expire:claim" // 抽奖 Stage 2:每小时扫描过期 pending_claim
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user