package cache import ( "context" "fmt" "net/url" "strings" "time" "github.com/redis/go-redis/v9" ) // RedisCache 基于 Redis 的缓存实现(可选增强)。 type RedisCache struct { client *redis.Client } // NewRedis 连接 Redis 并校验可用性。addr 支持两种形式: // - host:port(纯地址,库号由 db 参数指定) // - redis://[:password@]host:port[/db](URL 形式,URL 中的库号优先于 db 参数) func NewRedis(ctx context.Context, addr string, db int) (*RedisCache, error) { opts, err := buildRedisOptions(addr, db) if err != nil { return nil, err } client := redis.NewClient(opts) if err := client.Ping(ctx).Err(); err != nil { _ = client.Close() return nil, fmt.Errorf("cache: Redis 连接失败 %s: %w", addr, err) } return &RedisCache{client: client}, nil } // buildRedisOptions 构造 go-redis 连接选项(纯地址 / URL 形式统一入口)。 func buildRedisOptions(addr string, db int) (*redis.Options, error) { opts := &redis.Options{ Addr: addr, DB: db, DialTimeout: 5 * time.Second, ReadTimeout: 3 * time.Second, WriteTimeout: 3 * time.Second, PoolSize: 32, } if strings.HasPrefix(addr, "redis://") || strings.HasPrefix(addr, "rediss://") { u, err := redis.ParseURL(addr) if err != nil { return nil, fmt.Errorf("cache: Redis 地址解析失败 %s: %w", addr, err) } // URL 未显式携带库号(路径为空或 /)时用 db 参数;显式 /N 优先 if u.DB == 0 && !urlHasDBPath(addr) { u.DB = db } u.DialTimeout = opts.DialTimeout u.ReadTimeout = opts.ReadTimeout u.WriteTimeout = opts.WriteTimeout u.PoolSize = opts.PoolSize opts = u } return opts, nil } // urlHasDBPath 判断 redis:// URL 是否显式携带了库号路径(如 /5)。 func urlHasDBPath(raw string) bool { u, err := url.Parse(raw) if err != nil { return false } return strings.Trim(u.Path, "/") != "" } // Get 读取键值。 func (r *RedisCache) Get(ctx context.Context, key string) (string, error) { val, err := r.client.Get(ctx, key).Result() if err == redis.Nil { return "", ErrNotFound } return val, err } // Set 写入键值。 func (r *RedisCache) Set(ctx context.Context, key, value string, ttl time.Duration) error { return r.client.Set(ctx, key, value, ttl).Err() } // Delete 删除键。 func (r *RedisCache) Delete(ctx context.Context, keys ...string) error { if len(keys) == 0 { return nil } return r.client.Del(ctx, keys...).Err() } // Exists 判断键是否存在。 func (r *RedisCache) Exists(ctx context.Context, key string) (bool, error) { n, err := r.client.Exists(ctx, key).Result() return n > 0, err } // Incr 原子自增;首次创建时设置窗口 TTL。 // 使用 Lua 脚本保证 INCR+EXPIRE 原子性,避免多实例下窗口被反复重置。 func (r *RedisCache) Incr(ctx context.Context, key string, ttl time.Duration) (int64, error) { var incrScript = redis.NewScript(` local n = redis.call('INCR', KEYS[1]) if n == 1 and ARGV[1] ~= '0' then redis.call('PEXPIRE', KEYS[1], ARGV[1]) end return n `) ttlMs := int64(0) if ttl > 0 { ttlMs = ttl.Milliseconds() } return incrScript.Run(ctx, r.client, []string{key}, ttlMs).Int64() } // Close 关闭 Redis 连接。 func (r *RedisCache) Close() error { return r.client.Close() }