c92495c5b9
Closes HIF-3 (Stage 1 P0 from QA smoke)
F4 (P0): EligibilitySnapshot.UnmetReasons=\"\" → MySQL error 3140 (Invalid JSON text: The document is empty)
- 修法:方式 A 对称守卫 UnmetReasons=\"[]\" (与 PrizeSnapshot.Config 的 \"{}\" 守卫模式一致)
- Stage 1 只有 passed=true 分支进 insertSnapshots,语义正确
F5 (P2 顺手): EvaluatedAt 显式 time.Now() 避免 GORM zero time 触 sql_mode STRICT
回归护栏:TestInsertSnapshots_UnmetReasonsIsValidJSON 用 sqlmock per-arg matcher (unmetReasonsNotEmpty + evaluatedAtNotZero),退化时 t.Fatalf 立即抛出
CI 全绿;2 files, +108/-0;无 DB 变更
架构师侧透明:这是 PR C review 时漏检查——PrizeSnapshot.Config 的空串守卫看到了,但没同步检查 EligibilitySnapshot.UnmetReasons。single-code-path 的错觉是审查盲点,Stage 2 起会 sweep 所有 JSON/字符串写库字段。
556 lines
17 KiB
Go
556 lines
17 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/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,
|
||
// 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
|
||
}
|
||
|
||
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
|
||
}
|