Files
sub2api/backend/internal/repository/redeem_cache.go
T
TT 666a5c6f87 fix(redeem): use a fixed 10-minute failure window
The redeem failure counter used INCR followed by Expire(24h) on every
attempt, so each failed attempt refreshed the key TTL and the window
never actually closed for a user who kept retrying within 24h.

- Count attempts in a fixed 10-minute window via a small Lua script
  (INCR, then PEXPIRE only when the key is created or has no TTL), which
  is atomic and also repairs keys that lost their TTL.
- Move the counter to the redeem:ratelimit:v2: key space so pre-existing
  24h counters do not seed the new window.
- Rename the service threshold to redeemMaxFailedAttempts (30) to reflect
  that it bounds attempts per window, not per hour.

Public redeem behavior is unchanged apart from the window semantics:
the check fails open when the counter read fails, and a failed
increment never masks the underlying domain error.
2026-09-05 23:38:50 +08:00

75 lines
1.8 KiB
Go

package repository
import (
"context"
"fmt"
"time"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/redis/go-redis/v9"
)
const (
redeemRateLimitKeyPrefix = "redeem:ratelimit:v2:"
redeemLockKeyPrefix = "redeem:lock:"
redeemRateLimitWindow = 10 * time.Minute
)
var incrementRedeemAttemptScript = redis.NewScript(`
local current = redis.call('INCR', KEYS[1])
local ttl = redis.call('PTTL', KEYS[1])
if current == 1 or ttl == -1 then
redis.call('PEXPIRE', KEYS[1], ARGV[1])
end
return current
`)
// redeemRateLimitKey generates the Redis key for redeem attempt rate limiting.
func redeemRateLimitKey(userID int64) string {
return fmt.Sprintf("%s%d", redeemRateLimitKeyPrefix, userID)
}
// redeemLockKey generates the Redis key for redeem code locking.
func redeemLockKey(code string) string {
return redeemLockKeyPrefix + code
}
type redeemCache struct {
rdb *redis.Client
}
func NewRedeemCache(rdb *redis.Client) service.RedeemCache {
return &redeemCache{rdb: rdb}
}
func (c *redeemCache) GetRedeemAttemptCount(ctx context.Context, userID int64) (int, error) {
key := redeemRateLimitKey(userID)
count, err := c.rdb.Get(ctx, key).Int()
if err == redis.Nil {
return 0, nil
}
return count, err
}
func (c *redeemCache) IncrementRedeemAttemptCount(ctx context.Context, userID int64) error {
key := redeemRateLimitKey(userID)
return incrementRedeemAttemptScript.Run(
ctx,
c.rdb,
[]string{key},
redeemRateLimitWindow.Milliseconds(),
).Err()
}
func (c *redeemCache) AcquireRedeemLock(ctx context.Context, code string, ttl time.Duration) (bool, error) {
key := redeemLockKey(code)
return c.rdb.SetNX(ctx, key, 1, ttl).Result()
}
func (c *redeemCache) ReleaseRedeemLock(ctx context.Context, code string) error {
key := redeemLockKey(code)
return c.rdb.Del(ctx, key).Err()
}