Files
hi-server/internal/logic/lottery/draw/service.go
T
shanshanzhong147 5ef3f2717e feat(#4): 抽奖中奖用户文案调整
- 免费时长: 用户消息改为「稍后您的 N 天免费时长将会自动添加至您的账户。
  如果超过24小时未添加成功,请联系人工客服处理。」(N=中奖天数动态)
  ledger.payload.message 仍保留descriptive内部文案供后台对账
- 人工发放(crypto/manual): 消息改为「请凭此截图直接联系人工客服兑换奖励。」

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 02:56:38 -07:00

658 lines
22 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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/logic/lottery/handler"
"github.com/perfect-panel/server/internal/model/lottery"
"github.com/perfect-panel/server/pkg/limit"
"github.com/perfect-panel/server/pkg/logger"
"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
// 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
// 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 (auto handler) or create pending claim (manual handler).
dispatch, claimInfo, dispatchErr := s.dispatchOrEnqueueClaim(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, claimInfo, 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
}
// 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: "谢谢参与"}, pendingClaimInfo{}, nil
}
if s.deps.Registry == nil {
return lottery.DispatchResult{State: lottery.DispatchStatePendingClaim, Message: "请凭此截图直接联系人工客服兑换奖励。"}, pendingClaimInfo{}, nil
}
prizeHandler, err := s.deps.Registry.MustGet(prize.Type)
if err != nil {
if errors.Is(err, lottery.ErrHandlerNotRegistered) {
return lottery.DispatchResult{State: lottery.DispatchStatePendingClaim, Message: "请凭此截图直接联系人工客服兑换奖励。"}, pendingClaimInfo{}, nil
}
return lottery.DispatchResult{}, pendingClaimInfo{}, wrapInternal(err)
}
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
}
// 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,
// ClaimData 是 JSON 列,MySQL 拒绝空字符串(error 3140)——用户填领奖
// 表单前用 "{}" 兜底,用户 POST /claim 会覆盖真实数据。与
// PrizeSnapshot.Config、EligibilitySnapshot.UnmetReasons、
// GrantLedger.Payload 的守卫风格一致。
ClaimData: "{}",
Status: lottery.ClaimStatusPendingClaim,
ExpiresAt: expiresAt,
}
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 {
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),
}
}
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: claim,
}, nil
}
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,
ClaimFormSchema: claim.ClaimFormSchema,
},
}
if !claim.ExpiresAt.IsZero() {
res.Claim.ExpiresAt = claim.ExpiresAt.Unix()
}
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
}
// wrapInternal 把内部 error 转成对外的 LotteryInternalError code。
//
// HIF-4 F10msg 字段只带通用文案("抽奖服务暂时不可用"),err.Error() 的原文
// 只写日志,绝不外泄给 app 端。之前把 err.Error() 直接塞 msg 导致
// {"code":100500,"msg":"insert lottery_claim for draw 15: Error 3140 ..."} 这种
// 响应,泄露 DB 结构 + 撑爆前端 msg 字段。
func wrapInternal(err error) error {
if err == nil {
return nil
}
// Preserve already-coded errors (their msg 是设计过的对外文案,不动).
if _, ok := err.(*xerr.CodeError); ok {
return err
}
// 内部细节走日志,供运维/后端排查;err.Error() 不外传。
logger.WithContext(context.Background()).Error("[lottery draw internal error]",
logger.Field("error", err.Error()))
return xerr.NewErrCode(xerr.LotteryInternalError)
}
// ---- 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
}