做完了一部分,备份一下,防止以外删除

This commit is contained in:
2025-11-11 15:16:38 +08:00
parent 9600e5b6e0
commit e98dd4d725
39 changed files with 2423 additions and 183 deletions

167
pkg/config/config.go Normal file
View File

@@ -0,0 +1,167 @@
package config
import (
"errors"
"fmt"
"strings"
"sync/atomic"
"time"
"unsafe"
)
// globalConfig 保存当前配置,支持原子访问
var globalConfig atomic.Pointer[Config]
// Config 应用配置
type Config struct {
Server ServerConfig `mapstructure:"server"`
Redis RedisConfig `mapstructure:"redis"`
Logging LoggingConfig `mapstructure:"logging"`
Middleware MiddlewareConfig `mapstructure:"middleware"`
}
// ServerConfig HTTP 服务器配置
type ServerConfig struct {
Address string `mapstructure:"address"` // 例如 ":3000"
ReadTimeout time.Duration `mapstructure:"read_timeout"` // 例如 "10s"
WriteTimeout time.Duration `mapstructure:"write_timeout"` // 例如 "10s"
ShutdownTimeout time.Duration `mapstructure:"shutdown_timeout"` // 例如 "30s"
Prefork bool `mapstructure:"prefork"` // 多进程模式
}
// RedisConfig Redis 连接配置
type RedisConfig struct {
Address string `mapstructure:"address"` // 例如 "localhost"
Port int `mapstructure:"port"` // 例如 "6379"
Password string `mapstructure:"password"` // 无认证时留空
DB int `mapstructure:"db"` // 数据库编号0-15
PoolSize int `mapstructure:"pool_size"` // 最大连接数
MinIdleConns int `mapstructure:"min_idle_conns"` // 保活连接数
DialTimeout time.Duration `mapstructure:"dial_timeout"` // 例如 "5s"
ReadTimeout time.Duration `mapstructure:"read_timeout"` // 例如 "3s"
WriteTimeout time.Duration `mapstructure:"write_timeout"` // 例如 "3s"
}
// LoggingConfig 日志配置
type LoggingConfig struct {
Level string `mapstructure:"level"` // debug, info, warn, error
Development bool `mapstructure:"development"` // 启用开发模式(美化输出)
AppLog LogRotationConfig `mapstructure:"app_log"` // 应用日志配置
AccessLog LogRotationConfig `mapstructure:"access_log"` // HTTP 访问日志配置
}
// LogRotationConfig Lumberjack 日志轮转配置
type LogRotationConfig struct {
Filename string `mapstructure:"filename"` // 日志文件路径
MaxSize int `mapstructure:"max_size"` // 轮转前的最大大小MB
MaxBackups int `mapstructure:"max_backups"` // 保留的旧文件最大数量
MaxAge int `mapstructure:"max_age"` // 保留旧文件的最大天数
Compress bool `mapstructure:"compress"` // 压缩轮转的文件
}
// MiddlewareConfig 中间件配置
type MiddlewareConfig struct {
EnableAuth bool `mapstructure:"enable_auth"` // 启用 keyauth 中间件
EnableRateLimiter bool `mapstructure:"enable_rate_limiter"` // 启用限流器默认false
RateLimiter RateLimiterConfig `mapstructure:"rate_limiter"` // 限流器配置
}
// RateLimiterConfig 限流器配置
type RateLimiterConfig struct {
Max int `mapstructure:"max"` // 时间窗口内的最大请求数
Expiration time.Duration `mapstructure:"expiration"` // 时间窗口(例如 "1m"
Storage string `mapstructure:"storage"` // "memory" 或 "redis"
}
// Validate 验证配置值
func (c *Config) Validate() error {
// 服务器验证
if c.Server.Address == "" {
return fmt.Errorf("invalid configuration: server.address: must be non-empty (current value: empty)")
}
if c.Server.ReadTimeout < 5*time.Second || c.Server.ReadTimeout > 300*time.Second {
return fmt.Errorf("invalid configuration: server.read_timeout: duration out of range (current value: %s, expected: 5s-300s)", c.Server.ReadTimeout)
}
if c.Server.WriteTimeout < 5*time.Second || c.Server.WriteTimeout > 300*time.Second {
return fmt.Errorf("invalid configuration: server.write_timeout: duration out of range (current value: %s, expected: 5s-300s)", c.Server.WriteTimeout)
}
if c.Server.ShutdownTimeout < 10*time.Second || c.Server.ShutdownTimeout > 120*time.Second {
return fmt.Errorf("invalid configuration: server.shutdown_timeout: duration out of range (current value: %s, expected: 10s-120s)", c.Server.ShutdownTimeout)
}
// Redis 验证
if c.Redis.Address == "" {
return fmt.Errorf("invalid configuration: redis.address: must be non-empty (current value: empty)")
}
if !strings.Contains(c.Redis.Address, ":") {
return fmt.Errorf("invalid configuration: redis.address: invalid format (current value: %s, expected: HOST:PORT)", c.Redis.Address)
}
if c.Redis.DB < 0 || c.Redis.DB > 15 {
return fmt.Errorf("invalid configuration: redis.db: database number out of range (current value: %d, expected: 0-15)", c.Redis.DB)
}
if c.Redis.PoolSize <= 0 || c.Redis.PoolSize > 1000 {
return fmt.Errorf("invalid configuration: redis.pool_size: pool size out of range (current value: %d, expected: 1-1000)", c.Redis.PoolSize)
}
if c.Redis.MinIdleConns < 0 || c.Redis.MinIdleConns > c.Redis.PoolSize {
return fmt.Errorf("invalid configuration: redis.min_idle_conns: must be 0 to pool_size (current value: %d, pool_size: %d)", c.Redis.MinIdleConns, c.Redis.PoolSize)
}
// 日志验证
validLevels := map[string]bool{"debug": true, "info": true, "warn": true, "error": true}
if !validLevels[c.Logging.Level] {
return fmt.Errorf("invalid configuration: logging.level: invalid log level (current value: %s, expected: debug, info, warn, error)", c.Logging.Level)
}
if c.Logging.AppLog.Filename == "" {
return fmt.Errorf("invalid configuration: logging.app_log.filename: must be non-empty valid file path")
}
if c.Logging.AccessLog.Filename == "" {
return fmt.Errorf("invalid configuration: logging.access_log.filename: must be non-empty valid file path")
}
if c.Logging.AppLog.MaxSize < 1 || c.Logging.AppLog.MaxSize > 1000 {
return fmt.Errorf("invalid configuration: logging.app_log.max_size: size out of range (current value: %d, expected: 1-1000 MB)", c.Logging.AppLog.MaxSize)
}
if c.Logging.AccessLog.MaxSize < 1 || c.Logging.AccessLog.MaxSize > 1000 {
return fmt.Errorf("invalid configuration: logging.access_log.max_size: size out of range (current value: %d, expected: 1-1000 MB)", c.Logging.AccessLog.MaxSize)
}
// 中间件验证
if c.Middleware.RateLimiter.Max <= 0 {
c.Middleware.RateLimiter.Max = 100 // 默认值
}
if c.Middleware.RateLimiter.Max > 10000 {
return fmt.Errorf("invalid configuration: middleware.rate_limiter.max: request limit out of range (current value: %d, expected: 1-10000)", c.Middleware.RateLimiter.Max)
}
validStorage := map[string]bool{"memory": true, "redis": true}
if c.Middleware.RateLimiter.Storage != "" && !validStorage[c.Middleware.RateLimiter.Storage] {
return fmt.Errorf("invalid configuration: middleware.rate_limiter.storage: invalid storage type (current value: %s, expected: memory or redis)", c.Middleware.RateLimiter.Storage)
}
return nil
}
// Get 返回当前配置
func Get() *Config {
return globalConfig.Load()
}
// Set 原子地更新全局配置
func Set(cfg *Config) error {
if cfg == nil {
return errors.New("config cannot be nil")
}
if err := cfg.Validate(); err != nil {
return err
}
globalConfig.Store(cfg)
return nil
}
// unsafeSet 无验证设置配置(仅供热重载内部使用)
func unsafeSet(cfg *Config) {
globalConfig.Store(cfg)
}
// atomicSwap 原子地交换配置
func atomicSwap(new *Config) *Config {
return (*Config)(atomic.SwapPointer((*unsafe.Pointer)(unsafe.Pointer(&globalConfig)), unsafe.Pointer(new)))
}

93
pkg/config/loader.go Normal file
View File

@@ -0,0 +1,93 @@
package config
import (
"fmt"
"os"
"path/filepath"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/spf13/viper"
)
// Load 从文件和环境变量加载配置
func Load() (*Config, error) {
// 确定配置路径
configPath := os.Getenv(constants.EnvConfigPath)
if configPath == "" {
configPath = constants.DefaultConfigPath
}
// 检查环境特定配置dev, staging, prod
configEnv := os.Getenv(constants.EnvConfigEnv)
if configEnv != "" {
// 优先尝试环境特定配置
envConfigPath := fmt.Sprintf("configs/config.%s.yaml", configEnv)
if _, err := os.Stat(envConfigPath); err == nil {
configPath = envConfigPath
}
}
// 设置 Viper
viper.SetConfigFile(configPath)
viper.SetConfigType("yaml")
// 启用环境变量覆盖
viper.AutomaticEnv()
viper.SetEnvPrefix("APP")
// 读取配置文件
if err := viper.ReadInConfig(); err != nil {
return nil, fmt.Errorf("failed to read config file: %w", err)
}
// 反序列化到 Config 结构体
cfg := &Config{}
if err := viper.Unmarshal(cfg); err != nil {
return nil, fmt.Errorf("failed to unmarshal config: %w", err)
}
// 验证配置
if err := cfg.Validate(); err != nil {
return nil, err
}
// 设为全局配置
globalConfig.Store(cfg)
return cfg, nil
}
// Reload 重新加载当前配置文件
func Reload() (*Config, error) {
if err := viper.ReadInConfig(); err != nil {
return nil, fmt.Errorf("failed to reload config: %w", err)
}
cfg := &Config{}
if err := viper.Unmarshal(cfg); err != nil {
return nil, fmt.Errorf("failed to unmarshal config: %w", err)
}
// 设置前验证
if err := cfg.Validate(); err != nil {
return nil, err
}
// 原子交换
globalConfig.Store(cfg)
return cfg, nil
}
// GetConfigPath 返回当前已加载配置文件的绝对路径
func GetConfigPath() string {
configFile := viper.ConfigFileUsed()
if configFile == "" {
return ""
}
absPath, err := filepath.Abs(configFile)
if err != nil {
return configFile
}
return absPath
}

43
pkg/config/watcher.go Normal file
View File

@@ -0,0 +1,43 @@
package config
import (
"context"
"github.com/fsnotify/fsnotify"
"github.com/spf13/viper"
"go.uber.org/zap"
)
// Watch 监听配置文件变化
// 运行直到上下文被取消
func Watch(ctx context.Context, logger *zap.Logger) {
viper.WatchConfig()
viper.OnConfigChange(func(e fsnotify.Event) {
select {
case <-ctx.Done():
return // 如果上下文被取消则停止处理
default:
logger.Info("配置文件已更改", zap.String("file", e.Name))
// 尝试重新加载
newConfig, err := Reload()
if err != nil {
logger.Error("重新加载配置失败,保留先前配置",
zap.Error(err),
zap.String("file", e.Name),
)
return
}
logger.Info("配置重新加载成功",
zap.String("file", e.Name),
zap.String("server_address", newConfig.Server.Address),
zap.String("log_level", newConfig.Logging.Level),
)
}
})
// 阻塞直到上下文被取消
<-ctx.Done()
logger.Info("配置监听器已停止")
}

View File

@@ -0,0 +1,21 @@
package constants
// Fiber Locals 的上下文键
const (
ContextKeyRequestID = "requestid"
ContextKeyUserID = "user_id"
ContextKeyStartTime = "start_time"
)
// 配置环境变量
const (
EnvConfigPath = "CONFIG_PATH"
EnvConfigEnv = "CONFIG_ENV" // dev, staging, prod
)
// 默认配置值
const (
DefaultConfigPath = "configs/config.yaml"
DefaultServerAddr = ":3000"
DefaultRedisAddr = "localhost:6379"
)

13
pkg/constants/redis.go Normal file
View File

@@ -0,0 +1,13 @@
package constants
import "fmt"
// RedisAuthTokenKey 生成认证令牌的 Redis 键
func RedisAuthTokenKey(token string) string {
return fmt.Sprintf("auth:token:%s", token)
}
// RedisRateLimitKey 生成限流的 Redis 键
func RedisRateLimitKey(ip string) string {
return fmt.Sprintf("ratelimit:%s", ip)
}

53
pkg/database/redis.go Normal file
View File

@@ -0,0 +1,53 @@
package database
import (
"context"
"fmt"
"time"
"github.com/redis/go-redis/v9"
"go.uber.org/zap"
)
// RedisConfig Redis 配置
type RedisConfig struct {
Address string
Password string
DB int
PoolSize int
MinIdleConns int
DialTimeout time.Duration
ReadTimeout time.Duration
WriteTimeout time.Duration
}
// NewRedisClient 创建新的 Redis 客户端
func NewRedisClient(cfg RedisConfig, logger *zap.Logger) (*redis.Client, error) {
client := redis.NewClient(&redis.Options{
Addr: cfg.Address,
Password: cfg.Password,
DB: cfg.DB,
PoolSize: cfg.PoolSize,
MinIdleConns: cfg.MinIdleConns,
DialTimeout: cfg.DialTimeout,
ReadTimeout: cfg.ReadTimeout,
WriteTimeout: cfg.WriteTimeout,
MaxRetries: 3,
PoolTimeout: 4 * time.Second,
})
// 测试连接
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := client.Ping(ctx).Err(); err != nil {
return nil, fmt.Errorf("failed to connect to redis: %w", err)
}
logger.Info("Redis 连接成功",
zap.String("address", cfg.Address),
zap.Int("db", cfg.DB),
)
return client, nil
}

39
pkg/errors/codes.go Normal file
View File

@@ -0,0 +1,39 @@
package errors
// 应用错误码
const (
CodeSuccess = 0 // 成功
CodeInternalError = 1000 // 内部服务器错误
CodeMissingToken = 1001 // 缺失认证令牌
CodeInvalidToken = 1002 // 令牌无效或已过期
CodeTooManyRequests = 1003 // 请求过于频繁(限流)
CodeAuthServiceUnavailable = 1004 // 认证服务不可用Redis 宕机)
)
// ErrorMessage 表示双语错误消息
type ErrorMessage struct {
EN string
ZH string
}
// errorMessages 将错误码映射到双语消息
var errorMessages = map[int]ErrorMessage{
CodeSuccess: {"Success", "成功"},
CodeInternalError: {"Internal server error", "内部服务器错误"},
CodeMissingToken: {"Missing authentication token", "缺失认证令牌"},
CodeInvalidToken: {"Invalid or expired token", "令牌无效或已过期"},
CodeTooManyRequests: {"Too many requests", "请求过于频繁"},
CodeAuthServiceUnavailable: {"Authentication service unavailable", "认证服务不可用"},
}
// GetMessage 根据错误码和语言返回错误消息
func GetMessage(code int, lang string) string {
msg, ok := errorMessages[code]
if !ok {
return "Unknown error"
}
if lang == "zh" || lang == "zh-CN" {
return msg.ZH
}
return msg.EN
}

49
pkg/errors/errors.go Normal file
View File

@@ -0,0 +1,49 @@
package errors
import (
"errors"
"fmt"
)
// 中间件标准错误类型
var (
ErrMissingToken = errors.New("missing authentication token")
ErrInvalidToken = errors.New("invalid or expired token")
ErrRedisUnavailable = errors.New("redis unavailable")
ErrTooManyRequests = errors.New("too many requests")
)
// AppError 表示带错误码的应用错误
type AppError struct {
Code int // 应用错误码
Message string // 错误消息
Err error // 底层错误(可选)
}
func (e *AppError) Error() string {
if e.Err != nil {
return fmt.Sprintf("%s: %v", e.Message, e.Err)
}
return e.Message
}
func (e *AppError) Unwrap() error {
return e.Err
}
// New 创建新的 AppError
func New(code int, message string) *AppError {
return &AppError{
Code: code,
Message: message,
}
}
// Wrap 用错误码和消息包装现有错误
func Wrap(code int, message string, err error) *AppError {
return &AppError{
Code: code,
Message: message,
Err: err,
}
}

146
pkg/logger/logger.go Normal file
View File

@@ -0,0 +1,146 @@
package logger
import (
"go.uber.org/zap"
"go.uber.org/zap/zapcore"
"gopkg.in/natefinch/lumberjack.v2"
)
var (
// appLogger 应用日志记录器
appLogger *zap.Logger
// accessLogger 访问日志记录器
accessLogger *zap.Logger
)
// InitLoggers 初始化应用和访问日志记录器
func InitLoggers(
level string,
development bool,
appLogConfig LogRotationConfig,
accessLogConfig LogRotationConfig,
) error {
// 解析日志级别
zapLevel := parseLevel(level)
// 创建编码器配置
encoderConfig := zapcore.EncoderConfig{
TimeKey: "timestamp",
LevelKey: "level",
NameKey: "logger",
CallerKey: "caller",
MessageKey: "message",
StacktraceKey: "stacktrace",
LineEnding: zapcore.DefaultLineEnding,
EncodeLevel: zapcore.LowercaseLevelEncoder,
EncodeTime: zapcore.ISO8601TimeEncoder, // RFC3339 格式
EncodeDuration: zapcore.SecondsDurationEncoder,
EncodeCaller: zapcore.ShortCallerEncoder,
}
// 选择编码器(开发模式使用控制台,生产使用 JSON
var encoder zapcore.Encoder
if development {
encoder = zapcore.NewConsoleEncoder(encoderConfig)
} else {
encoder = zapcore.NewJSONEncoder(encoderConfig)
}
// 创建应用日志核心
appCore := zapcore.NewCore(
encoder,
zapcore.AddSync(newLumberjackLogger(appLogConfig)),
zapLevel,
)
// 创建访问日志核心(始终使用 JSON
accessCore := zapcore.NewCore(
zapcore.NewJSONEncoder(encoderConfig),
zapcore.AddSync(newLumberjackLogger(accessLogConfig)),
zapcore.InfoLevel, // 访问日志始终使用 info 级别
)
// 构建日志记录器
if development {
// 开发模式:添加调用者信息和堆栈跟踪
appLogger = zap.New(appCore, zap.AddCaller(), zap.AddStacktrace(zapcore.ErrorLevel), zap.Development())
} else {
// 生产模式:添加调用者信息,仅在 error 时添加堆栈跟踪
appLogger = zap.New(appCore, zap.AddCaller(), zap.AddStacktrace(zapcore.ErrorLevel))
}
// 访问日志不需要调用者信息
accessLogger = zap.New(accessCore)
return nil
}
// GetAppLogger 返回应用日志记录器
func GetAppLogger() *zap.Logger {
if appLogger == nil {
// 如果未初始化,返回 nop logger
return zap.NewNop()
}
return appLogger
}
// GetAccessLogger 返回访问日志记录器
func GetAccessLogger() *zap.Logger {
if accessLogger == nil {
// 如果未初始化,返回 nop logger
return zap.NewNop()
}
return accessLogger
}
// Sync 刷新所有日志缓冲区
func Sync() error {
if appLogger != nil {
if err := appLogger.Sync(); err != nil {
return err
}
}
if accessLogger != nil {
if err := accessLogger.Sync(); err != nil {
return err
}
}
return nil
}
// parseLevel 解析日志级别字符串
func parseLevel(level string) zapcore.Level {
switch level {
case "debug":
return zapcore.DebugLevel
case "info":
return zapcore.InfoLevel
case "warn":
return zapcore.WarnLevel
case "error":
return zapcore.ErrorLevel
default:
return zapcore.InfoLevel
}
}
// newLumberjackLogger 创建 Lumberjack 日志轮转器
func newLumberjackLogger(config LogRotationConfig) *lumberjack.Logger {
return &lumberjack.Logger{
Filename: config.Filename,
MaxSize: config.MaxSize,
MaxBackups: config.MaxBackups,
MaxAge: config.MaxAge,
Compress: config.Compress,
LocalTime: true, // 使用本地时间
}
}
// LogRotationConfig 日志轮转配置(从 config 包复制以避免循环依赖)
type LogRotationConfig struct {
Filename string
MaxSize int
MaxBackups int
MaxAge int
Compress bool
}

52
pkg/logger/middleware.go Normal file
View File

@@ -0,0 +1,52 @@
package logger
import (
"time"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/gofiber/fiber/v2"
"go.uber.org/zap"
)
// Middleware 创建 Fiber 日志中间件
// 记录所有 HTTP 请求到访问日志
func Middleware() fiber.Handler {
return func(c *fiber.Ctx) error {
// 记录请求开始时间
startTime := time.Now()
c.Locals(constants.ContextKeyStartTime, startTime)
// 处理请求
err := c.Next()
// 计算请求持续时间
duration := time.Since(startTime)
// 获取请求 ID由 requestid 中间件设置)
requestID := ""
if rid := c.Locals(constants.ContextKeyRequestID); rid != nil {
requestID = rid.(string)
}
// 获取用户 ID由 auth 中间件设置)
userID := ""
if uid := c.Locals(constants.ContextKeyUserID); uid != nil {
userID = uid.(string)
}
// 记录访问日志
accessLogger := GetAccessLogger()
accessLogger.Info("",
zap.String("method", c.Method()),
zap.String("path", c.Path()),
zap.Int("status", c.Response().StatusCode()),
zap.Float64("duration_ms", float64(duration.Microseconds())/1000.0),
zap.String("request_id", requestID),
zap.String("ip", c.IP()),
zap.String("user_agent", c.Get("User-Agent")),
zap.String(constants.ContextKeyUserID, userID),
)
return err
}
}

46
pkg/response/response.go Normal file
View File

@@ -0,0 +1,46 @@
package response
import (
"time"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/gofiber/fiber/v2"
)
// Response 统一 API 响应结构
type Response struct {
Code int `json:"code"` // 应用错误码0 = 成功)
Data any `json:"data"` // 响应数据(对象、数组或 null
Message string `json:"msg"` // 可读消息
Timestamp string `json:"timestamp"` // ISO 8601 时间戳
}
// Success 返回成功响应
func Success(c *fiber.Ctx, data any) error {
return c.JSON(Response{
Code: errors.CodeSuccess,
Data: data,
Message: "success",
Timestamp: time.Now().Format(time.RFC3339),
})
}
// Error 返回错误响应
func Error(c *fiber.Ctx, httpStatus int, code int, message string) error {
return c.Status(httpStatus).JSON(Response{
Code: code,
Data: nil,
Message: message,
Timestamp: time.Now().Format(time.RFC3339),
})
}
// SuccessWithMessage 返回带自定义消息的成功响应
func SuccessWithMessage(c *fiber.Ctx, data any, message string) error {
return c.JSON(Response{
Code: errors.CodeSuccess,
Data: data,
Message: message,
Timestamp: time.Now().Format(time.RFC3339),
})
}

65
pkg/validator/token.go Normal file
View File

@@ -0,0 +1,65 @@
package validator
import (
"context"
"time"
"github.com/redis/go-redis/v9"
"go.uber.org/zap"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
)
// TokenValidator 令牌验证器
type TokenValidator struct {
redis *redis.Client
logger *zap.Logger
}
// NewTokenValidator 创建新的令牌验证器
func NewTokenValidator(rdb *redis.Client, logger *zap.Logger) *TokenValidator {
return &TokenValidator{
redis: rdb,
logger: logger,
}
}
// Validate 验证令牌并返回用户 ID
func (v *TokenValidator) Validate(token string) (string, error) {
ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
defer cancel()
// 检查 Redis 可用性(失败关闭策略)
if err := v.redis.Ping(ctx).Err(); err != nil {
v.logger.Error("Redis 不可用",
zap.Error(err),
)
return "", errors.ErrRedisUnavailable
}
// 从 Redis 获取用户 ID
userID, err := v.redis.Get(ctx, constants.RedisAuthTokenKey(token)).Result()
if err == redis.Nil {
// 令牌不存在或已过期
return "", errors.ErrInvalidToken
}
if err != nil {
v.logger.Error("Redis 获取失败",
zap.Error(err),
zap.String("token_key", constants.RedisAuthTokenKey(token)),
)
return "", err
}
return userID, nil
}
// IsAvailable 检查 Redis 是否可用
func (v *TokenValidator) IsAvailable() bool {
ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
defer cancel()
err := v.redis.Ping(ctx).Err()
return err == nil
}