新功能(#3): 抽奖 Stage 1 收官 — 用户 API + 后台 CRUD + 集成
Closes HIF-3 Stage 1 完整闭环 PR C:用户 API + 后台 CRUD + 抽奖事务服务 + 审计 + QA curl。合并后 Stage 1 可交测试。 架构师 review R1(rulecaps depth≤8/nodes≤64/bytes≤8KB)+ R2(InviteHook source_ref 加 order: 前缀)已全部落地。 - 迁移 02158_admin_action_log:后台写操作审计 - xerr 100xxx 段:抽奖错误码(NotEligible/NoChances/ActivityEnded/RateLimited/NotClaimable/InternalError/RuleTooDeep/RuleTooMany/RuleTooLarge) - feature flag config.Lottery.Enable 默认 false,合并后线上零副作用 - draw service:feature flag → rate limit → pre-tx reads → nonce dedupe → Consume → Pick → 乐观扣库存 → 双快照 → Dispatch → finalize - 用户 API 4 个:GET /config、POST /draw、GET /records、POST /claim(Stage 1 返回 100010) - 后台 CRUD:活动 / 奖品 / rules PUT(rulecaps gate)/ chances/grant - audit.WriteAdminAction:与调用方 tx 同生共死,SHA1 body 摘要 - QA 脚本:qa/lottery/stage1_curl.sh 全链路 curl 测试覆盖:model 76.5% / draw 69% / handler 68.3% / hook 91.9% / rulecaps 87.8% / audit 100% 100 并发抢库存 + 10000 次概率分布 e2e 推 QA 环境(sqlmock 无法忠实模拟 InnoDB 行锁) CI 全绿:构建/Vet/测试 + golangci-lint
This commit is contained in:
@@ -0,0 +1,547 @@
|
||||
// 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/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
|
||||
}
|
||||
|
||||
// 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.
|
||||
dispatch, dispatchErr := s.dispatchIfAutomatic(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, 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,
|
||||
}
|
||||
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) {
|
||||
if !draw.IsWin {
|
||||
return lottery.DispatchResult{State: lottery.DispatchStateAutoClaimed, Message: "谢谢参与"}, nil
|
||||
}
|
||||
if s.deps.Registry == nil {
|
||||
return lottery.DispatchResult{State: lottery.DispatchStatePendingClaim, Message: "等待人工发放"}, nil
|
||||
}
|
||||
handler, 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{}, wrapInternal(err)
|
||||
}
|
||||
if !handler.IsAuto() {
|
||||
return lottery.DispatchResult{State: lottery.DispatchStatePendingClaim, Message: "等待人工发放"}, nil
|
||||
}
|
||||
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),
|
||||
}
|
||||
return handler.Dispatch(ctx, tx, dispatchReq)
|
||||
}
|
||||
|
||||
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),
|
||||
}
|
||||
}
|
||||
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: "",
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func buildResult(draw *lottery.Draw, prize lottery.Prize, dispatch lottery.DispatchResult, 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,
|
||||
},
|
||||
}
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,513 @@
|
||||
package draw
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/DATA-DOG/go-sqlmock"
|
||||
"github.com/perfect-panel/server/internal/model/lottery"
|
||||
"github.com/perfect-panel/server/pkg/xerr"
|
||||
"gorm.io/driver/mysql"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// ---- test fakes ------------------------------------------------------------
|
||||
|
||||
type fakeRateLimiter struct {
|
||||
mu sync.Mutex
|
||||
err error
|
||||
calls int
|
||||
}
|
||||
|
||||
func (f *fakeRateLimiter) Allow(_ context.Context, _ int64) error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.calls++
|
||||
return f.err
|
||||
}
|
||||
|
||||
type fakeChance struct {
|
||||
mu sync.Mutex
|
||||
consumeRemaining int64
|
||||
consumeErr error
|
||||
consumeCalls int
|
||||
queryRemaining int64
|
||||
}
|
||||
|
||||
func (f *fakeChance) Grant(context.Context, int64, int64, string, string, int) error {
|
||||
return nil
|
||||
}
|
||||
func (f *fakeChance) Consume(_ context.Context, _ *gorm.DB, _, _ int64) (int64, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.consumeCalls++
|
||||
if f.consumeErr != nil {
|
||||
return 0, f.consumeErr
|
||||
}
|
||||
return f.consumeRemaining, nil
|
||||
}
|
||||
func (f *fakeChance) Query(_ context.Context, _, _ int64) (int64, error) {
|
||||
return f.queryRemaining, nil
|
||||
}
|
||||
|
||||
type fakeEvaluator struct {
|
||||
passed bool
|
||||
unmet []lottery.UnmetReason
|
||||
err error
|
||||
}
|
||||
|
||||
func (f *fakeEvaluator) Evaluate(context.Context, *lottery.EligibilityRule, lottery.RuleContext) (bool, []lottery.UnmetReason, error) {
|
||||
return f.passed, f.unmet, f.err
|
||||
}
|
||||
|
||||
type fakePicker struct {
|
||||
idx int
|
||||
err error
|
||||
}
|
||||
|
||||
func (f *fakePicker) Pick([]lottery.Prize) (int, error) { return f.idx, f.err }
|
||||
|
||||
type fakeContextBuilder struct{}
|
||||
|
||||
func (fakeContextBuilder) Build(_ context.Context, uid int64) (lottery.RuleContext, error) {
|
||||
return lottery.RuleContext{UserId: uid}, nil
|
||||
}
|
||||
|
||||
type recordingHandler struct {
|
||||
handlerType string
|
||||
auto bool
|
||||
result lottery.DispatchResult
|
||||
err error
|
||||
calls int
|
||||
}
|
||||
|
||||
func (h *recordingHandler) Type() string { return h.handlerType }
|
||||
func (h *recordingHandler) IsAuto() bool { return h.auto }
|
||||
func (h *recordingHandler) Dispatch(_ context.Context, _ *gorm.DB, _ lottery.DispatchRequest) (lottery.DispatchResult, error) {
|
||||
h.calls++
|
||||
return h.result, h.err
|
||||
}
|
||||
func (h *recordingHandler) ValidateClaim([]byte) error { return nil }
|
||||
|
||||
// stubRegistry only knows what we register.
|
||||
type stubRegistry struct {
|
||||
handlers map[string]lottery.PrizeHandler
|
||||
}
|
||||
|
||||
func (r *stubRegistry) Get(t string) (lottery.PrizeHandler, bool) {
|
||||
h, ok := r.handlers[t]
|
||||
return h, ok
|
||||
}
|
||||
func (r *stubRegistry) MustGet(t string) (lottery.PrizeHandler, error) {
|
||||
if h, ok := r.handlers[t]; ok {
|
||||
return h, nil
|
||||
}
|
||||
return nil, lottery.ErrHandlerNotRegistered
|
||||
}
|
||||
|
||||
// ---- shared harness --------------------------------------------------------
|
||||
|
||||
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 fmt.Errorf("actual sql %q does not contain %q", actual, expected)
|
||||
})))
|
||||
if err != nil {
|
||||
t.Fatalf("sqlmock: %v", err)
|
||||
}
|
||||
db, err := gorm.Open(mysql.New(mysql.Config{Conn: sqlDB, SkipInitializeWithVersion: true}), &gorm.Config{})
|
||||
if err != nil {
|
||||
_ = sqlDB.Close()
|
||||
t.Fatalf("gorm: %v", err)
|
||||
}
|
||||
return db, mock, func() { _ = sqlDB.Close() }
|
||||
}
|
||||
|
||||
// expectRunningActivity sets up the pre-tx activity load.
|
||||
func expectRunningActivity(mock sqlmock.Sqlmock, activityId int64) {
|
||||
now := time.Now()
|
||||
mock.ExpectQuery("FROM `lottery_activity`").
|
||||
WillReturnRows(sqlmock.NewRows([]string{
|
||||
"id", "title", "start_at", "end_at", "status", "grid_size", "eligibility", "chance_sources", "unmet_action",
|
||||
}).AddRow(activityId, "test", now.Add(-1*time.Hour), now.Add(24*time.Hour), lottery.ActivityStatusRunning, 9, "{}", "[]", "block"))
|
||||
}
|
||||
|
||||
// expectPrizePool sets up the pre-tx prize load.
|
||||
func expectPrizePool(mock sqlmock.Sqlmock, activityId int64, prizes ...lottery.Prize) {
|
||||
rows := sqlmock.NewRows([]string{"id", "activity_id", "slot", "type", "name", "icon_url", "config", "weight", "total_stock", "remaining_stock", "is_fallback", "version"})
|
||||
for _, p := range prizes {
|
||||
rows.AddRow(p.Id, p.ActivityId, p.Slot, p.Type, p.Name, p.IconURL, p.Config, p.Weight, p.TotalStock, p.RemainingStock, p.IsFallback, p.Version)
|
||||
}
|
||||
mock.ExpectQuery("FROM `lottery_prize`").WillReturnRows(rows)
|
||||
}
|
||||
|
||||
// expectExistingDrawEmpty sets up the tx-inner nonce lookup returning no rows.
|
||||
func expectExistingDrawEmpty(mock sqlmock.Sqlmock) {
|
||||
mock.ExpectQuery("FROM `lottery_draw`").WillReturnError(gorm.ErrRecordNotFound)
|
||||
}
|
||||
|
||||
// ---- Test cases ------------------------------------------------------------
|
||||
|
||||
func TestDraw_RejectsWhenFeatureFlagDisabled(t *testing.T) {
|
||||
db, _, cleanup := newTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
svc := NewService(Deps{
|
||||
DB: db,
|
||||
Enabled: false,
|
||||
})
|
||||
_, err := svc.Draw(context.Background(), Request{UserId: 42, ActivityId: 100, ClientNonce: "n1"})
|
||||
code, ok := errAsCode(err)
|
||||
if !ok || code != xerr.LotteryActivityEnded {
|
||||
t.Fatalf("expected LotteryActivityEnded when disabled, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDraw_ValidatesRequest(t *testing.T) {
|
||||
db, _, cleanup := newTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
svc := NewService(Deps{DB: db, Enabled: true})
|
||||
cases := []Request{
|
||||
{UserId: 0, ActivityId: 1, ClientNonce: "n"},
|
||||
{UserId: 1, ActivityId: 0, ClientNonce: "n"},
|
||||
{UserId: 1, ActivityId: 1, ClientNonce: ""},
|
||||
{UserId: 1, ActivityId: 1, ClientNonce: strings.Repeat("x", 65)},
|
||||
}
|
||||
for i, c := range cases {
|
||||
if _, err := svc.Draw(context.Background(), c); err == nil {
|
||||
t.Fatalf("case %d expected error, got nil", i)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDraw_RateLimited(t *testing.T) {
|
||||
db, _, cleanup := newTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
limiter := &fakeRateLimiter{err: ErrRateLimited}
|
||||
svc := NewService(Deps{DB: db, Enabled: true, RateLimiter: limiter})
|
||||
_, err := svc.Draw(context.Background(), Request{UserId: 1, ActivityId: 1, ClientNonce: "n"})
|
||||
code, ok := errAsCode(err)
|
||||
if !ok || code != xerr.LotteryRateLimited {
|
||||
t.Fatalf("expected LotteryRateLimited, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDraw_NonceIdempotency_ReturnsExisting(t *testing.T) {
|
||||
db, mock, cleanup := newTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
expectRunningActivity(mock, 100)
|
||||
expectPrizePool(mock, 100, lottery.Prize{Id: 1, ActivityId: 100, Slot: 0, Type: lottery.PrizeTypeNone, Name: "谢谢参与", Config: "{}", Weight: 1})
|
||||
|
||||
mock.ExpectBegin()
|
||||
// nonce hit — the flow short-circuits
|
||||
mock.ExpectQuery("FROM `lottery_draw`").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id", "user_id", "activity_id", "client_nonce", "prize_id", "is_win", "dispatch_state", "drawn_at"}).
|
||||
AddRow(int64(999), int64(42), int64(100), "same-nonce", nil, false, lottery.DispatchStateAutoClaimed, time.Now()))
|
||||
// snapshot lookup
|
||||
mock.ExpectQuery("FROM `lottery_prize_snapshot`").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id", "draw_id", "prize_id", "slot", "type", "name", "config"}).
|
||||
AddRow(int64(1), int64(999), int64(0), 0, lottery.PrizeTypeNone, "谢谢参与", "{}"))
|
||||
mock.ExpectCommit()
|
||||
|
||||
chance := &fakeChance{queryRemaining: 3}
|
||||
svc := NewService(Deps{
|
||||
DB: db,
|
||||
Enabled: true,
|
||||
Chance: chance,
|
||||
Evaluator: &fakeEvaluator{passed: true},
|
||||
Picker: &fakePicker{idx: 0},
|
||||
Registry: &stubRegistry{handlers: map[string]lottery.PrizeHandler{}},
|
||||
ContextBuilder: fakeContextBuilder{},
|
||||
})
|
||||
res, err := svc.Draw(context.Background(), Request{UserId: 42, ActivityId: 100, ClientNonce: "same-nonce"})
|
||||
if err != nil {
|
||||
t.Fatalf("Draw: %v", err)
|
||||
}
|
||||
if res.DrawId != 999 {
|
||||
t.Fatalf("expected reuse existing draw id=999, got %d", res.DrawId)
|
||||
}
|
||||
if chance.consumeCalls != 0 {
|
||||
t.Fatalf("must NOT Consume a chance on nonce replay")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDraw_NoChancesReturnsCode(t *testing.T) {
|
||||
db, mock, cleanup := newTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
expectRunningActivity(mock, 100)
|
||||
expectPrizePool(mock, 100, lottery.Prize{Id: 1, ActivityId: 100, Slot: 0, Type: lottery.PrizeTypeNone, Name: "谢谢参与", Config: "{}", Weight: 1})
|
||||
|
||||
mock.ExpectBegin()
|
||||
expectExistingDrawEmpty(mock)
|
||||
mock.ExpectRollback()
|
||||
|
||||
chance := &fakeChance{consumeErr: lottery.ErrNoChances}
|
||||
svc := NewService(Deps{
|
||||
DB: db,
|
||||
Enabled: true,
|
||||
Chance: chance,
|
||||
Evaluator: &fakeEvaluator{passed: true},
|
||||
Picker: &fakePicker{idx: 0},
|
||||
Registry: &stubRegistry{handlers: map[string]lottery.PrizeHandler{}},
|
||||
ContextBuilder: fakeContextBuilder{},
|
||||
})
|
||||
_, err := svc.Draw(context.Background(), Request{UserId: 1, ActivityId: 100, ClientNonce: "n"})
|
||||
code, ok := errAsCode(err)
|
||||
if !ok || code != xerr.LotteryNoChances {
|
||||
t.Fatalf("expected LotteryNoChances, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDraw_NotEligibleRejectsBeforeConsume(t *testing.T) {
|
||||
db, mock, cleanup := newTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
expectRunningActivity(mock, 100)
|
||||
|
||||
svc := NewService(Deps{
|
||||
DB: db,
|
||||
Enabled: true,
|
||||
Chance: &fakeChance{},
|
||||
Evaluator: &fakeEvaluator{passed: false, unmet: []lottery.UnmetReason{{Rule: "invite_count", Hint: "need 3"}}},
|
||||
Picker: &fakePicker{},
|
||||
Registry: &stubRegistry{},
|
||||
ContextBuilder: fakeContextBuilder{},
|
||||
})
|
||||
_, err := svc.Draw(context.Background(), Request{UserId: 1, ActivityId: 100, ClientNonce: "n"})
|
||||
code, ok := errAsCode(err)
|
||||
if !ok || code != xerr.LotteryNotEligible {
|
||||
t.Fatalf("expected LotteryNotEligible, got %v", err)
|
||||
}
|
||||
// Ensure no mock expectations remain (we didn't set up prize load).
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatalf("expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDraw_SuccessAutoClaimedNoneReturnsDrawWithoutDispatch(t *testing.T) {
|
||||
db, mock, cleanup := newTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
expectRunningActivity(mock, 100)
|
||||
// Two prizes: one none (weight 100) — picker returns idx 0 always
|
||||
expectPrizePool(mock, 100,
|
||||
lottery.Prize{Id: 1, ActivityId: 100, Slot: 0, Type: lottery.PrizeTypeNone, Name: "谢谢参与", Config: "{}", Weight: 100},
|
||||
)
|
||||
|
||||
mock.ExpectBegin()
|
||||
expectExistingDrawEmpty(mock)
|
||||
// insert draw
|
||||
mock.ExpectExec("INSERT INTO `lottery_draw`").
|
||||
WillReturnResult(sqlmock.NewResult(555, 1))
|
||||
// insert prize_snapshot
|
||||
mock.ExpectExec("INSERT INTO `lottery_prize_snapshot`").
|
||||
WillReturnResult(sqlmock.NewResult(1, 1))
|
||||
// insert eligibility_snapshot
|
||||
mock.ExpectExec("INSERT INTO `lottery_eligibility_snapshot`").
|
||||
WillReturnResult(sqlmock.NewResult(1, 1))
|
||||
// finalize draw state
|
||||
mock.ExpectExec("UPDATE `lottery_draw`").
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
mock.ExpectCommit()
|
||||
|
||||
chance := &fakeChance{consumeRemaining: 2}
|
||||
svc := NewService(Deps{
|
||||
DB: db,
|
||||
Enabled: true,
|
||||
Chance: chance,
|
||||
Evaluator: &fakeEvaluator{passed: true},
|
||||
Picker: &fakePicker{idx: 0},
|
||||
Registry: &stubRegistry{handlers: map[string]lottery.PrizeHandler{}},
|
||||
ContextBuilder: fakeContextBuilder{},
|
||||
})
|
||||
res, err := svc.Draw(context.Background(), Request{UserId: 1, ActivityId: 100, ClientNonce: "unique-1"})
|
||||
if err != nil {
|
||||
t.Fatalf("Draw: %v", err)
|
||||
}
|
||||
if res.IsWin {
|
||||
t.Fatalf("none prize should not count as win")
|
||||
}
|
||||
if res.DrawId == 0 {
|
||||
t.Fatalf("expected draw id from LastInsertId")
|
||||
}
|
||||
if res.ChancesRemaining != 2 {
|
||||
t.Fatalf("expected remaining=2 from Consume, got %d", res.ChancesRemaining)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDraw_LimitedStockFallback(t *testing.T) {
|
||||
db, mock, cleanup := newTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
expectRunningActivity(mock, 100)
|
||||
// prize 0 = limited (remaining=0 → sold out); prize 1 = fallback
|
||||
expectPrizePool(mock, 100,
|
||||
lottery.Prize{Id: 10, ActivityId: 100, Slot: 0, Type: "vpn_duration", Name: "3天", Config: `{"duration_days":3}`, Weight: 100, TotalStock: sql.NullInt64{Int64: 1, Valid: true}, RemainingStock: sql.NullInt64{Int64: 1, Valid: true}},
|
||||
lottery.Prize{Id: 20, ActivityId: 100, Slot: 1, Type: lottery.PrizeTypeNone, Name: "谢谢参与", Config: "{}", Weight: 0, IsFallback: true},
|
||||
)
|
||||
|
||||
mock.ExpectBegin()
|
||||
expectExistingDrawEmpty(mock)
|
||||
// stock decrement returns 0 rows affected → sold out
|
||||
mock.ExpectExec("UPDATE `lottery_prize`").
|
||||
WillReturnResult(sqlmock.NewResult(0, 0))
|
||||
mock.ExpectExec("INSERT INTO `lottery_draw`").
|
||||
WillReturnResult(sqlmock.NewResult(778, 1))
|
||||
mock.ExpectExec("INSERT INTO `lottery_prize_snapshot`").
|
||||
WillReturnResult(sqlmock.NewResult(1, 1))
|
||||
mock.ExpectExec("INSERT INTO `lottery_eligibility_snapshot`").
|
||||
WillReturnResult(sqlmock.NewResult(1, 1))
|
||||
mock.ExpectExec("UPDATE `lottery_draw`").
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
mock.ExpectCommit()
|
||||
|
||||
svc := NewService(Deps{
|
||||
DB: db,
|
||||
Enabled: true,
|
||||
Chance: &fakeChance{consumeRemaining: 1},
|
||||
Evaluator: &fakeEvaluator{passed: true},
|
||||
Picker: &fakePicker{idx: 0},
|
||||
Registry: &stubRegistry{handlers: map[string]lottery.PrizeHandler{}},
|
||||
ContextBuilder: fakeContextBuilder{},
|
||||
})
|
||||
res, err := svc.Draw(context.Background(), Request{UserId: 1, ActivityId: 100, ClientNonce: "n"})
|
||||
if err != nil {
|
||||
t.Fatalf("Draw: %v", err)
|
||||
}
|
||||
if res.IsWin {
|
||||
t.Fatalf("fallback none should not win")
|
||||
}
|
||||
if res.DrawId != 778 {
|
||||
t.Fatalf("expected draw id 778, got %d", res.DrawId)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDraw_WinCallsAutoHandlerDispatch(t *testing.T) {
|
||||
db, mock, cleanup := newTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
expectRunningActivity(mock, 100)
|
||||
expectPrizePool(mock, 100,
|
||||
lottery.Prize{Id: 30, ActivityId: 100, Slot: 5, Type: lottery.PrizeTypeVPNDuration, Name: "3 天", Config: `{"duration_days":3}`, Weight: 100},
|
||||
)
|
||||
|
||||
handler := &recordingHandler{
|
||||
handlerType: lottery.PrizeTypeVPNDuration,
|
||||
auto: true,
|
||||
result: lottery.DispatchResult{State: lottery.DispatchStateAutoClaimed, Message: "已加 3 天"},
|
||||
}
|
||||
|
||||
mock.ExpectBegin()
|
||||
expectExistingDrawEmpty(mock)
|
||||
mock.ExpectExec("INSERT INTO `lottery_draw`").
|
||||
WillReturnResult(sqlmock.NewResult(1234, 1))
|
||||
mock.ExpectExec("INSERT INTO `lottery_prize_snapshot`").
|
||||
WillReturnResult(sqlmock.NewResult(1, 1))
|
||||
mock.ExpectExec("INSERT INTO `lottery_eligibility_snapshot`").
|
||||
WillReturnResult(sqlmock.NewResult(1, 1))
|
||||
mock.ExpectExec("UPDATE `lottery_draw`").
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
mock.ExpectCommit()
|
||||
|
||||
svc := NewService(Deps{
|
||||
DB: db,
|
||||
Enabled: true,
|
||||
Chance: &fakeChance{consumeRemaining: 0},
|
||||
Evaluator: &fakeEvaluator{passed: true},
|
||||
Picker: &fakePicker{idx: 0},
|
||||
Registry: &stubRegistry{handlers: map[string]lottery.PrizeHandler{lottery.PrizeTypeVPNDuration: handler}},
|
||||
ContextBuilder: fakeContextBuilder{},
|
||||
})
|
||||
res, err := svc.Draw(context.Background(), Request{UserId: 1, ActivityId: 100, ClientNonce: "w"})
|
||||
if err != nil {
|
||||
t.Fatalf("Draw: %v", err)
|
||||
}
|
||||
if !res.IsWin {
|
||||
t.Fatalf("expected IsWin=true for vpn_duration")
|
||||
}
|
||||
if handler.calls != 1 {
|
||||
t.Fatalf("expected handler.Dispatch called once, got %d", handler.calls)
|
||||
}
|
||||
if res.Claim.AutoClaimed != true {
|
||||
t.Fatalf("expected AutoClaimed=true, got %+v", res.Claim)
|
||||
}
|
||||
if res.Message != "已加 3 天" {
|
||||
t.Fatalf("expected message from Dispatch, got %q", res.Message)
|
||||
}
|
||||
if res.Prize == nil || res.Prize.Type != lottery.PrizeTypeVPNDuration {
|
||||
t.Fatalf("expected prize summary, got %+v", res.Prize)
|
||||
}
|
||||
// Prize.Config should be embedded json.RawMessage — verify decodes
|
||||
var cfg map[string]any
|
||||
if err := json.Unmarshal(res.Prize.Config, &cfg); err != nil {
|
||||
t.Fatalf("Prize.Config invalid: %v", err)
|
||||
}
|
||||
if cfg["duration_days"].(float64) != 3 {
|
||||
t.Fatalf("expected duration_days=3, got %v", cfg["duration_days"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestDraw_UnregisteredAutoHandlerFallsBackToPendingClaim(t *testing.T) {
|
||||
db, mock, cleanup := newTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
expectRunningActivity(mock, 100)
|
||||
expectPrizePool(mock, 100,
|
||||
lottery.Prize{Id: 40, ActivityId: 100, Slot: 0, Type: "encrypted", Name: "Encrypted", Config: "{}", Weight: 100},
|
||||
)
|
||||
|
||||
mock.ExpectBegin()
|
||||
expectExistingDrawEmpty(mock)
|
||||
mock.ExpectExec("INSERT INTO `lottery_draw`").
|
||||
WillReturnResult(sqlmock.NewResult(1, 1))
|
||||
mock.ExpectExec("INSERT INTO `lottery_prize_snapshot`").
|
||||
WillReturnResult(sqlmock.NewResult(1, 1))
|
||||
mock.ExpectExec("INSERT INTO `lottery_eligibility_snapshot`").
|
||||
WillReturnResult(sqlmock.NewResult(1, 1))
|
||||
mock.ExpectExec("UPDATE `lottery_draw`").
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
mock.ExpectCommit()
|
||||
|
||||
svc := NewService(Deps{
|
||||
DB: db,
|
||||
Enabled: true,
|
||||
Chance: &fakeChance{consumeRemaining: 0},
|
||||
Evaluator: &fakeEvaluator{passed: true},
|
||||
Picker: &fakePicker{idx: 0},
|
||||
Registry: &stubRegistry{handlers: map[string]lottery.PrizeHandler{}},
|
||||
ContextBuilder: fakeContextBuilder{},
|
||||
})
|
||||
res, err := svc.Draw(context.Background(), Request{UserId: 1, ActivityId: 100, ClientNonce: "w2"})
|
||||
if err != nil {
|
||||
t.Fatalf("Draw: %v", err)
|
||||
}
|
||||
if res.Claim.Required != true {
|
||||
t.Fatalf("expected Claim.Required=true when handler unregistered, got %+v", res.Claim)
|
||||
}
|
||||
}
|
||||
|
||||
// errAsCode helps assert xerr.CodeError codes.
|
||||
func errAsCode(err error) (uint32, bool) {
|
||||
if err == nil {
|
||||
return 0, false
|
||||
}
|
||||
var ce *xerr.CodeError
|
||||
if errors.As(err, &ce) {
|
||||
return ce.GetErrCode(), true
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
Reference in New Issue
Block a user