topfans/backend/services/assetService/service/mint_rate_limiter.go
zerosaturation 878bd46399 fix(backend): financial correctness — settlement/hours/mint idempotency (batch 1)
- settlement(1.1): exhibition_revenue_records 加 UNIQUE(exhibition_id,cycle_start_time)
  + CreateRevenueRecord ON CONFLICT DO NOTHING; MQ 用 settled_at 替代复用 is_processed;
  恢复扫描器过滤改 settled_at IS NULL; created_at 统一毫秒; 删死代码 cleanup_worker.go。
- hours(1.2): 新增 exhibition_hours_log/asset_exhibition_hours_log(source_id 唯一)幂等表;
  fan_profile/assetLevel 的 AddExhibitionHours 按 sourceID 幂等(事务包裹); 存量重算脚本。
- mint(1.4/1.5/1.6): crystal_transaction_records (source_id,change_type) 部分唯一索引
  + UpdateCrystalBalance/CreateMintOrder 幂等; 保底改 crypto/rand; 下线伪 tx_hash;
  doMint Redis Lua 原子限流。
- migrations 001/002/003; 各服务单测(自包含, 缺 DB t.Skip)。

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-23 01:11:54 +08:00

61 lines
1.9 KiB
Go

package service
import (
"context"
"fmt"
"time"
"github.com/redis/go-redis/v9"
)
// mintRateLimitScript atomically increments the current window counter and
// assigns its TTL when the key is first created.
var mintRateLimitScript = redis.NewScript(`
local n = redis.call("INCR", KEYS[1])
if n == 1 then
redis.call("EXPIRE", KEYS[1], ARGV[1])
end
return {n, n <= tonumber(ARGV[2]) and 1 or 0}
`)
// MintRateLimiter limits successful mint attempts with a Redis fixed window.
type MintRateLimiter struct {
rdb *redis.Client
limit int64
window time.Duration
}
// NewMintRateLimiter creates a Redis-backed mint rate limiter.
func NewMintRateLimiter(rdb *redis.Client, limit int64, window time.Duration) *MintRateLimiter {
return &MintRateLimiter{rdb: rdb, limit: limit, window: window}
}
// IncrAndCheck atomically increments the owner's counter and returns its new
// value and whether it is within the configured limit.
func (l *MintRateLimiter) IncrAndCheck(ctx context.Context, ownerUID int64, assetType string) (int64, bool, error) {
if l == nil || l.rdb == nil {
return 0, false, fmt.Errorf("redis client is not initialized")
}
windowSeconds := int64(l.window.Seconds())
result, err := mintRateLimitScript.Run(ctx, l.rdb, []string{l.key(ownerUID, assetType)}, windowSeconds, l.limit).Result()
if err != nil {
return 0, false, fmt.Errorf("redis EVAL mint rate limit: %w", err)
}
values, ok := result.([]interface{})
if !ok || len(values) != 2 {
return 0, false, fmt.Errorf("redis EVAL returned unexpected shape: %v", result)
}
count, countOK := values[0].(int64)
allowed, allowedOK := values[1].(int64)
if !countOK || !allowedOK {
return 0, false, fmt.Errorf("redis EVAL returned unexpected values: %v", result)
}
return count, allowed == 1, nil
}
func (l *MintRateLimiter) key(ownerUID int64, assetType string) string {
return fmt.Sprintf("periph:mint:%s:%d:%s", assetType, ownerUID, time.Now().UTC().Format("20060102"))
}