Merge branch 'refactor/consolidate-failable-apis'
This commit is contained in:
@@ -49,6 +49,16 @@ xlgo 框架更新日志。本文档遵循 [Keep a Changelog](https://keepachange
|
||||
- **`cron.ParseCron` 非法表达式改为 fail-fast panic**(cron/cron.go,M13):旧行为会把非法表达式静默回退为每分钟执行,容易让拼写错误变成高频任务。动态输入请用 `ParseCronStrict` 处理 error;确实需要旧回退语义时改用新增 `ParseCronOrDefault`。
|
||||
- **repository 查询保护默认开启**(repository/repository.go,M5/N5):`FindAll` 默认最多返回 `DefaultFindAllLimit=1000` 条;明确需要全表扫描时改用 `FindAllUnbounded`。`FindPage*` / `QueryBuilder.Page` 会归一化 `page/pageSize` 并限制 `MaxPageSize=100`、`MaxPage=10000`。`Find*Ordered` / `QueryBuilder.Order` 只接受简单字段排序(如 `created_at DESC, id ASC`),复杂表达式/raw SQL 会返回 `ErrUnsafeOrder`;`UpdateBatch` 字段名不合法返回 `ErrUnsafeField`。
|
||||
|
||||
- **同义可失败 API 收敛:错误不再吞并**(cache/jwt/storage/ratelimit):按"同一能力只保留一个主入口、框架不替上层吞错、安全路径默认 fail-closed"原则,收敛历史双轨 API。详见下述分项;自定义 `CacheService`/`Storage` 实现需同步更新签名(编译失败即迁移信号)。
|
||||
|
||||
- **cache**:`CacheService.Get` / `Exists` 改为 `(bool, error)`--命中 `(true,nil)`、未命中 `(false,nil)`、Redis 未就绪/命令错误/反序列化失败返回 `(false,err)`。删除 `cache.GetE` / `cache.ExistsE` / `CacheGetter` / `CacheExistChecker` / `redisCache.GetE` / `redisCache.ExistsE`(不保留 deprecated wrapper)。新增包级 `cache.Get(ctx,key,dest) (bool,error)` / `cache.Exists(ctx,key) (bool,error)`。`GetWithPrefix` 改为 `(bool,error)`。迁移:`hit, err := cache.Get(ctx, key, &v); if err != nil { return err }; if !hit { /* miss */ }`。
|
||||
|
||||
- **jwt**:`TokenBlacklist.IsBlacklisted` 改为 `(bool, error)`,删除 `IsBlacklistedE`。`IsTokenRevoked` 改为 `(bool, error)`。`ParseToken` 默认从 **fail-open 改为 fail-closed**--黑名单后端不可检查时返回 `ErrBlacklistUnavailable` 拒绝该 Token(无 Redis 部署不再支持可靠撤销)。删除 `ParseTokenFailClosed`(主 API 已 fail-closed)。保留 `ParseTokenWithBlacklistPolicy(token, policy)` + `BlacklistPolicy`/`BlacklistFailOpen`/`BlacklistFailClosed` 供显式 fail-open(仅无 Redis 或低安全场景)。迁移:无 Redis 部署需启用 Redis,或显式 `jwt.ParseTokenWithBlacklistPolicy(token, jwt.BlacklistFailOpen)`。
|
||||
|
||||
- **storage**:`Storage.Exists` 改为 `(bool, error)`--存在 `(true,nil)`、不存在 `(false,nil)`、未初始化/路径非法/穿越/后端错误返回 `(false,err)`。`LocalStorage.Exists` 区分 `os.IsNotExist`(not found)与其他 `os.Stat` 错误;`OSSStorage.Exists` 区分 OSS 404/NoSuchKey(not found)与鉴权/网络错误。包级 `storage.Exists` 同步签名,未初始化返回 `ErrStorageNotInitialized`。新增 `ErrReadTooLarge`:`LocalStorage.Get` / `OSSStorage.Get` 读取超 `maxReadBytes` 上限原误用 `ErrInvalidPath`(路径无效语义不贴切),改为 `ErrReadTooLarge`。迁移:`ok, err := storage.Exists(p); if err != nil { return err }; if !ok { /* not found */ }`。
|
||||
|
||||
- **ratelimit**:Redis 限流器策略收敛为配置型 API。`NewRedisRateLimiter(keyPrefix, rate, window, opts ...RedisRateLimiterOption)` 新增可变参数,`WithFailClosed(true)` 替代原 `NewRedisRateLimiterFailClosed`。`RedisRateLimit` / `CustomRedisRateLimit` / `RedisRateLimitWithIdentifier` 同步加 `opts` 参数。删除 `NewRedisRateLimiterFailClosed` / `RedisRateLimitFailClosed` / `CustomRedisRateLimitFailClosed`。`UploadRedisRateLimit` 由 fail-open 改为 **fail-closed**(上传属资源敏感操作,Redis 故障时拒绝以防限流静默失效)。迁移:`middleware.RedisRateLimit("k", 100, middleware.WithFailClosed(true))` 替代原 `RedisRateLimitFailClosed("k", 100)`。
|
||||
|
||||
### Security 🔒
|
||||
|
||||
- **MySQL 连接支持 TLS**(M-config-2):`DatabaseConfig.TLS` 为 true 时 `MySQLDSN` 追加 `tls=true`(go-sql-driver/mysql v1.7.0 内置安全语义:系统根 CA + ServerName 自动取自 Host + 证书校验,无需注册)。配合 `DatabaseConfig.TLSRootCA`(PEM 路径)可指定私有 CA/自签证书,由 `database` 包在 `InitDB` 时 `RegisterTLSConfig` 注册命名配置(`config.MySQLTLSConfigName`);CA 不可读或非 PEM 时 fail-fast,不静默回退明文。
|
||||
|
||||
@@ -529,9 +529,11 @@ c := cache.GetCache()
|
||||
// 设置缓存
|
||||
c.Set(ctx, "user:1", userData, 30*time.Minute)
|
||||
|
||||
// 获取缓存
|
||||
// 获取缓存(命中返回 true,未命中返回 false,nil,后端错误返回 err)
|
||||
var user User
|
||||
if c.Get(ctx, "user:1", &user) {
|
||||
if hit, err := c.Get(ctx, "user:1", &user); err != nil {
|
||||
return err
|
||||
} else if hit {
|
||||
// 缓存命中
|
||||
}
|
||||
|
||||
@@ -541,8 +543,11 @@ c.Delete(ctx, "user:1")
|
||||
// 批量删除(按模式)
|
||||
c.DeleteByPattern(ctx, "user:*")
|
||||
|
||||
// 检查是否存在
|
||||
exists := c.Exists(ctx, "user:1")
|
||||
// 检查是否存在(未命中 false,nil;后端错误返回 err)
|
||||
exists, err := c.Exists(ctx, "user:1")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
```
|
||||
|
||||
### 5.3 键名前缀管理(多站点共用 Redis)
|
||||
@@ -562,7 +567,10 @@ cache.KSession("sid") // → "session:my_app:sid"
|
||||
|
||||
// 使用带前缀的缓存
|
||||
c.Set(ctx, cache.K("user:1"), userData, ttl)
|
||||
c.Get(ctx, cache.K("user:1"), &user)
|
||||
hit, err := c.Get(ctx, cache.K("user:1"), &user)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
```
|
||||
|
||||
### 5.4 分布式锁(安全增强版)
|
||||
@@ -920,13 +928,13 @@ r.Use(middleware.CustomRateLimit(50, time.Minute)) // 每分钟50次
|
||||
r.Use(middleware.RedisRateLimit("api_limit", 100)) // 每分钟100次(fail-open:Redis 故障时放行)
|
||||
r.Use(middleware.LoginRedisRateLimit()) // 登录限流(fail-closed:Redis 故障时拒绝,防爆破)
|
||||
r.Use(middleware.APIRedisRateLimit()) // API限流(fail-open)
|
||||
r.Use(middleware.UploadRedisRateLimit()) // 上传限流(fail-open)
|
||||
r.Use(middleware.UploadRedisRateLimit()) // 上传限流(fail-closed:资源敏感,Redis 故障时拒绝)
|
||||
|
||||
// 自定义 Redis 限流
|
||||
r.Use(middleware.CustomRedisRateLimit("custom", 50, time.Minute)) // fail-open
|
||||
r.Use(middleware.CustomRedisRateLimitFailClosed("sensitive", 50, time.Minute)) // fail-closed(安全场景)
|
||||
// 自定义 Redis 限流(默认 fail-open,传 WithFailClosed(true) 切换为 fail-closed)
|
||||
r.Use(middleware.CustomRedisRateLimit("custom", 50, time.Minute)) // fail-open
|
||||
r.Use(middleware.CustomRedisRateLimit("sensitive", 50, time.Minute, middleware.WithFailClosed(true))) // fail-closed(安全场景)
|
||||
|
||||
// 自定义标识限流(如按用户ID)
|
||||
// 自定义标识限流(如按用户ID,同样支持 WithFailClosed)
|
||||
r.Use(middleware.RedisRateLimitWithIdentifier("user_limit", 100, func(c *gin.Context) string {
|
||||
return fmt.Sprintf("user:%d", middleware.GetUserID(c))
|
||||
}))
|
||||
@@ -936,9 +944,9 @@ defer middleware.StopRateLimiters()
|
||||
```
|
||||
|
||||
> **fail-open vs fail-closed(H4c)**:Redis 限流器在 Redis 故障时有两种策略——
|
||||
> - **fail-open**(`RedisRateLimit`/`APIRedisRateLimit`/`UploadRedisRateLimit`/`CustomRedisRateLimit`,默认):Redis 故障时放行,避免影响业务,但限流静默失效。
|
||||
> - **fail-closed**(`RedisRateLimitFailClosed`/`LoginRedisRateLimit`/`CustomRedisRateLimitFailClosed`):Redis 故障时拒绝(HTTP 503),防限流静默失效。**安全敏感场景(登录防爆破、敏感操作)必须用 fail-closed**。
|
||||
> `RedisRateLimiter` 可经 `NewRedisRateLimiterFailClosed` 构造或 `SetFailClosed(true)` 切换策略。
|
||||
> - **fail-open**(默认):Redis 故障时放行,避免影响业务,但限流静默失效。
|
||||
> - **fail-closed**(`WithFailClosed(true)`):Redis 故障时拒绝(HTTP 503),防限流静默失效。**安全敏感场景(登录防爆破、上传、敏感操作)必须用 fail-closed**。
|
||||
> `RedisRateLimiter` 可经 `NewRedisRateLimiter(..., WithFailClosed(true))` 构造或 `SetFailClosed(true)` 切换策略。
|
||||
|
||||
**内存限流 vs Redis 限流:**
|
||||
- 内存限流:单实例使用,简单高效
|
||||
@@ -1115,7 +1123,7 @@ import "github.com/EthanCodeCraft/xlgo-core/jwt"
|
||||
// 生成Token(自动包含唯一 JTI)
|
||||
token, err := jwt.GenerateToken(userID, username, "admin", "admin")
|
||||
|
||||
// 解析Token
|
||||
// 解析Token(默认 fail-closed:黑名单后端不可检查时拒绝 Token,需 Redis)
|
||||
claims, err := jwt.ParseToken(tokenString)
|
||||
|
||||
// 使Token失效(使用 JTI,内存占用约 30 字节)
|
||||
@@ -1229,8 +1237,12 @@ err := storage.Delete(path)
|
||||
// 获取文件内容
|
||||
data, err := storage.Get(path)
|
||||
|
||||
// 检查文件是否存在
|
||||
if storage.Exists(path) {
|
||||
// 检查文件是否存在(不存在 false,nil;后端错误返回 err)
|
||||
ok, err := storage.Exists(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if ok {
|
||||
// 文件存在
|
||||
}
|
||||
```
|
||||
|
||||
@@ -21,8 +21,7 @@ db := database.GetDB()
|
||||
myDB := database.NewManager(cfg)
|
||||
myDB.Open(ctx) // 独立实例,不受全局影响
|
||||
database.SetDefaultManager(myDB) // 提升为全局默认,并关闭旧默认 manager
|
||||
mockCache := &fakeCacheSvc{}
|
||||
cache.SetDefaultCacheManager(&cache.CacheManager{}) // 测试注入
|
||||
cache.SetDefaultCacheManager(&cache.CacheManager{}) // 测试注入自定义 CacheManager
|
||||
```
|
||||
|
||||
### 区别于一般 Gin 脚手架的几点
|
||||
@@ -351,19 +350,14 @@ cacheService := cache.GetCache()
|
||||
// 设置缓存
|
||||
cacheService.Set(ctx, "user:1", user, 10*time.Minute)
|
||||
|
||||
// 获取缓存
|
||||
// 获取缓存(命中返回 true,未命中返回 false,nil,后端错误返回 err)
|
||||
var user User
|
||||
if cacheService.Get(ctx, "user:1", &user) {
|
||||
// 缓存命中
|
||||
}
|
||||
|
||||
// 严格区分缓存未命中与 Redis/反序列化错误
|
||||
if hit, err := cache.GetE(ctx, "user:1", &user); err != nil {
|
||||
if hit, err := cacheService.Get(ctx, "user:1", &user); err != nil {
|
||||
return err
|
||||
} else if hit {
|
||||
// 缓存命中
|
||||
}
|
||||
if exists, err := cache.ExistsE(ctx, "user:1"); err != nil {
|
||||
if exists, err := cacheService.Exists(ctx, "user:1"); err != nil {
|
||||
return err
|
||||
} else if exists {
|
||||
// key 存在
|
||||
@@ -383,10 +377,11 @@ cacheService.DeleteByPattern(ctx, "user:*")
|
||||
token, err := jwt.GenerateToken(userID, username, "admin", "admin")
|
||||
|
||||
// 解析 Token
|
||||
// ParseToken 默认 fail-closed:黑名单后端不可检查时拒绝 Token(需 Redis)
|
||||
claims, err := jwt.ParseToken(tokenString)
|
||||
|
||||
// 安全敏感路由可要求黑名单检查 fail-closed
|
||||
claims, err = jwt.ParseTokenFailClosed(tokenString)
|
||||
// 需显式 fail-open(仅无 Redis 或低安全场景)用:
|
||||
claims, err = jwt.ParseTokenWithBlacklistPolicy(tokenString, jwt.BlacklistFailOpen)
|
||||
|
||||
// 使 Token 失效(使用 JTI,高效)
|
||||
jwt.InvalidateToken(tokenString)
|
||||
@@ -491,8 +486,11 @@ err := storage.Delete(path)
|
||||
// 获取文件内容
|
||||
data, err := storage.Get(path)
|
||||
|
||||
// 检查文件是否存在
|
||||
exists := storage.Exists(path)
|
||||
// 检查文件是否存在(不存在 false,nil;后端错误返回 err)
|
||||
ok, err := storage.Exists(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
```
|
||||
|
||||
### SSE 流式响应
|
||||
|
||||
Vendored
+19
-71
@@ -15,28 +15,17 @@ import (
|
||||
|
||||
// CacheService 缓存服务接口
|
||||
type CacheService interface {
|
||||
// Get 获取缓存值,如果存在则反序列化到 dest 并返回 true
|
||||
Get(ctx context.Context, key string, dest any) bool
|
||||
// Get 获取缓存值,命中则反序列化到 dest 并返回 (true, nil);未命中返回 (false, nil);
|
||||
// Redis 未就绪、命令错误或反序列化失败返回 (false, err)。调用方须显式处理错误。
|
||||
Get(ctx context.Context, key string, dest any) (bool, error)
|
||||
// Set 设置缓存值
|
||||
Set(ctx context.Context, key string, value any, ttl time.Duration) error
|
||||
// Delete 删除缓存
|
||||
Delete(ctx context.Context, key string) error
|
||||
// DeleteByPattern 按模式删除缓存
|
||||
DeleteByPattern(ctx context.Context, pattern string) error
|
||||
// Exists 检查缓存是否存在
|
||||
Exists(ctx context.Context, key string) bool
|
||||
}
|
||||
|
||||
// CacheExistChecker is implemented by cache backends that can distinguish a
|
||||
// missing key from a backend failure.
|
||||
type CacheExistChecker interface {
|
||||
ExistsE(ctx context.Context, key string) (bool, error)
|
||||
}
|
||||
|
||||
// CacheGetter is implemented by cache backends that can distinguish a cache
|
||||
// miss from a backend failure while loading a value.
|
||||
type CacheGetter interface {
|
||||
GetE(ctx context.Context, key string, dest any) (bool, error)
|
||||
// Exists 检查缓存是否存在;未命中返回 (false, nil),后端错误返回 (false, err)。
|
||||
Exists(ctx context.Context, key string) (bool, error)
|
||||
}
|
||||
|
||||
// redisCache Redis 缓存实现。
|
||||
@@ -56,32 +45,9 @@ func NewRedisCache() CacheService {
|
||||
return &redisCache{}
|
||||
}
|
||||
|
||||
// Get 获取缓存值
|
||||
func (c *redisCache) Get(ctx context.Context, key string, dest any) bool {
|
||||
cli := c.client()
|
||||
if cli == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
val, err := cli.Get(ctx, key).Result()
|
||||
if err != nil {
|
||||
if err != redis.Nil {
|
||||
logger.Warn("缓存获取失败", zap.String("key", key), zap.Error(err))
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
if err := json.Unmarshal([]byte(val), dest); err != nil {
|
||||
logger.Warn("缓存反序列化失败", zap.String("key", key), zap.Error(err))
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// GetE loads a cached JSON value and returns backend/deserialize errors to
|
||||
// callers that need to distinguish a cache miss from Redis or data failures.
|
||||
func (c *redisCache) GetE(ctx context.Context, key string, dest any) (bool, error) {
|
||||
// Get 获取缓存值。命中返回 (true, nil);未命中返回 (false, nil);
|
||||
// Redis 未就绪返回 (false, ErrRedisNotReady);Redis 命令错误或反序列化失败返回 (false, err)。
|
||||
func (c *redisCache) Get(ctx context.Context, key string, dest any) (bool, error) {
|
||||
cli := c.client()
|
||||
if cli == nil {
|
||||
return false, ErrRedisNotReady
|
||||
@@ -181,19 +147,9 @@ func (c *redisCache) DeleteByPattern(ctx context.Context, pattern string) error
|
||||
return nil
|
||||
}
|
||||
|
||||
// Exists 检查缓存是否存在
|
||||
func (c *redisCache) Exists(ctx context.Context, key string) bool {
|
||||
ok, err := c.ExistsE(ctx, key)
|
||||
if err != nil {
|
||||
logger.Warn("缓存存在性检查失败", zap.String("key", key), zap.Error(err))
|
||||
return false
|
||||
}
|
||||
return ok
|
||||
}
|
||||
|
||||
// ExistsE checks whether key exists and returns Redis/backend errors to callers
|
||||
// that need to distinguish a missing key from a cache outage.
|
||||
func (c *redisCache) ExistsE(ctx context.Context, key string) (bool, error) {
|
||||
// Exists 检查缓存是否存在。命中返回 (true, nil);未命中返回 (false, nil);
|
||||
// Redis 未就绪返回 (false, ErrRedisNotReady);命令错误返回 (false, err)。
|
||||
func (c *redisCache) Exists(ctx context.Context, key string) (bool, error) {
|
||||
cli := c.client()
|
||||
if cli == nil {
|
||||
return false, ErrRedisNotReady
|
||||
@@ -271,30 +227,22 @@ func GetCache() CacheService {
|
||||
return GetDefaultCache().Get()
|
||||
}
|
||||
|
||||
// ExistsE checks whether key exists and returns backend errors. It complements
|
||||
// the legacy bool-only CacheService.Exists method without changing that public
|
||||
// interface for downstream custom cache implementations.
|
||||
func ExistsE(ctx context.Context, key string) (bool, error) {
|
||||
// Get 获取全局缓存值。命中返回 (true, nil);未命中返回 (false, nil);
|
||||
// 缓存未初始化或 Redis 错误返回 (false, err)。
|
||||
func Get(ctx context.Context, key string, dest any) (bool, error) {
|
||||
svc := GetCache()
|
||||
if svc == nil {
|
||||
return false, ErrRedisNotReady
|
||||
}
|
||||
if checker, ok := svc.(CacheExistChecker); ok {
|
||||
return checker.ExistsE(ctx, key)
|
||||
}
|
||||
return svc.Exists(ctx, key), nil
|
||||
return svc.Get(ctx, key, dest)
|
||||
}
|
||||
|
||||
// GetE loads a cached JSON value and returns backend/deserialize errors. It
|
||||
// complements the legacy bool-only CacheService.Get method without changing
|
||||
// that public interface for downstream custom cache implementations.
|
||||
func GetE(ctx context.Context, key string, dest any) (bool, error) {
|
||||
// Exists 检查全局缓存是否存在。未命中返回 (false, nil);缓存未初始化或
|
||||
// Redis 错误返回 (false, err)。
|
||||
func Exists(ctx context.Context, key string) (bool, error) {
|
||||
svc := GetCache()
|
||||
if svc == nil {
|
||||
return false, ErrRedisNotReady
|
||||
}
|
||||
if getter, ok := svc.(CacheGetter); ok {
|
||||
return getter.GetE(ctx, key, dest)
|
||||
}
|
||||
return svc.Get(ctx, key, dest), nil
|
||||
return svc.Exists(ctx, key)
|
||||
}
|
||||
|
||||
Vendored
+56
-23
@@ -67,70 +67,70 @@ func TestM10RawHelpersReturnRedisNotReady(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestM10ExistsEReturnsBackendErrors(t *testing.T) {
|
||||
func TestM10ExistsReturnsBackendErrors(t *testing.T) {
|
||||
setupM10MiniRedis(t)
|
||||
c := &redisCache{}
|
||||
ctx := context.Background()
|
||||
|
||||
exists, err := c.ExistsE(ctx, "missing")
|
||||
exists, err := c.Exists(ctx, "missing")
|
||||
if err != nil {
|
||||
t.Fatalf("ExistsE missing err = %v", err)
|
||||
t.Fatalf("Exists missing err = %v", err)
|
||||
}
|
||||
if exists {
|
||||
t.Fatal("ExistsE missing = true, want false")
|
||||
t.Fatal("Exists missing = true, want false")
|
||||
}
|
||||
|
||||
if err := c.Set(ctx, "present", "value", time.Minute); err != nil {
|
||||
t.Fatalf("Set present: %v", err)
|
||||
}
|
||||
exists, err = c.ExistsE(ctx, "present")
|
||||
exists, err = c.Exists(ctx, "present")
|
||||
if err != nil || !exists {
|
||||
t.Fatalf("ExistsE present = %v, err=%v; want true,nil", exists, err)
|
||||
t.Fatalf("Exists present = %v, err=%v; want true,nil", exists, err)
|
||||
}
|
||||
|
||||
canceled, cancel := context.WithCancel(ctx)
|
||||
cancel()
|
||||
if exists, err = c.ExistsE(canceled, "present"); err == nil || exists {
|
||||
t.Fatalf("ExistsE canceled = %v, err=%v; want false,error", exists, err)
|
||||
if exists, err = c.Exists(canceled, "present"); err == nil || exists {
|
||||
t.Fatalf("Exists canceled = %v, err=%v; want false,error", exists, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestM10GetEReturnsBackendAndDecodeErrors(t *testing.T) {
|
||||
func TestM10GetReturnsBackendAndDecodeErrors(t *testing.T) {
|
||||
setupM10MiniRedis(t)
|
||||
c := &redisCache{}
|
||||
ctx := context.Background()
|
||||
|
||||
var got string
|
||||
ok, err := c.GetE(ctx, "missing", &got)
|
||||
ok, err := c.Get(ctx, "missing", &got)
|
||||
if err != nil || ok {
|
||||
t.Fatalf("GetE missing = %v, err=%v; want false,nil", ok, err)
|
||||
t.Fatalf("Get missing = %v, err=%v; want false,nil", ok, err)
|
||||
}
|
||||
|
||||
if err := c.Set(ctx, "present", "value", time.Minute); err != nil {
|
||||
t.Fatalf("Set present: %v", err)
|
||||
}
|
||||
ok, err = c.GetE(ctx, "present", &got)
|
||||
ok, err = c.Get(ctx, "present", &got)
|
||||
if err != nil || !ok || got != "value" {
|
||||
t.Fatalf("GetE present = %v, %q, err=%v; want true,value,nil", ok, got, err)
|
||||
t.Fatalf("Get present = %v, %q, err=%v; want true,value,nil", ok, got, err)
|
||||
}
|
||||
|
||||
if err := c.client().Set(ctx, "bad-json", "{", time.Minute).Err(); err != nil {
|
||||
t.Fatalf("Set bad-json: %v", err)
|
||||
}
|
||||
ok, err = c.GetE(ctx, "bad-json", &got)
|
||||
ok, err = c.Get(ctx, "bad-json", &got)
|
||||
if err == nil || ok {
|
||||
t.Fatalf("GetE bad-json = %v, err=%v; want false,error", ok, err)
|
||||
t.Fatalf("Get bad-json = %v, err=%v; want false,error", ok, err)
|
||||
}
|
||||
|
||||
canceled, cancel := context.WithCancel(ctx)
|
||||
cancel()
|
||||
ok, err = c.GetE(canceled, "present", &got)
|
||||
ok, err = c.Get(canceled, "present", &got)
|
||||
if err == nil || ok {
|
||||
t.Fatalf("GetE canceled = %v, err=%v; want false,error", ok, err)
|
||||
t.Fatalf("Get canceled = %v, err=%v; want false,error", ok, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestM10PackageExistsEUsesOptionalInterface(t *testing.T) {
|
||||
func TestM10PackageExistsReturnsBackendErrors(t *testing.T) {
|
||||
setupM10MiniRedis(t)
|
||||
orig := GetDefaultCache()
|
||||
t.Cleanup(func() { SetDefaultCacheManager(orig) })
|
||||
@@ -141,13 +141,13 @@ func TestM10PackageExistsEUsesOptionalInterface(t *testing.T) {
|
||||
if err := GetCache().Set(ctx, "present", "value", time.Minute); err != nil {
|
||||
t.Fatalf("Set through facade: %v", err)
|
||||
}
|
||||
exists, err := ExistsE(ctx, "present")
|
||||
exists, err := Exists(ctx, "present")
|
||||
if err != nil || !exists {
|
||||
t.Fatalf("facade ExistsE = %v, err=%v; want true,nil", exists, err)
|
||||
t.Fatalf("facade Exists = %v, err=%v; want true,nil", exists, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestM10PackageGetEUsesOptionalInterface(t *testing.T) {
|
||||
func TestM10PackageGetReturnsBackendErrors(t *testing.T) {
|
||||
setupM10MiniRedis(t)
|
||||
orig := GetDefaultCache()
|
||||
t.Cleanup(func() { SetDefaultCacheManager(orig) })
|
||||
@@ -159,8 +159,41 @@ func TestM10PackageGetEUsesOptionalInterface(t *testing.T) {
|
||||
t.Fatalf("Set through facade: %v", err)
|
||||
}
|
||||
var got string
|
||||
ok, err := GetE(ctx, "present", &got)
|
||||
ok, err := Get(ctx, "present", &got)
|
||||
if err != nil || !ok || got != "value" {
|
||||
t.Fatalf("facade GetE = %v, %q, err=%v; want true,value,nil", ok, got, err)
|
||||
t.Fatalf("facade Get = %v, %q, err=%v; want true,value,nil", ok, got, err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestM10CustomCacheServiceCompile asserts a user-provided CacheService
|
||||
// implementation compiles against the (bool, error) contract. This is a
|
||||
// compile-time guard: if the interface changes again, this stops building.
|
||||
type customCacheSvc struct{}
|
||||
|
||||
func (customCacheSvc) Get(ctx context.Context, key string, dest any) (bool, error) { return false, nil }
|
||||
func (customCacheSvc) Set(ctx context.Context, key string, value any, ttl time.Duration) error {
|
||||
return nil
|
||||
}
|
||||
func (customCacheSvc) Delete(ctx context.Context, key string) error { return nil }
|
||||
func (customCacheSvc) DeleteByPattern(ctx context.Context, p string) error { return nil }
|
||||
func (customCacheSvc) Exists(ctx context.Context, key string) (bool, error) { return false, nil }
|
||||
|
||||
func TestM10CustomCacheServiceCompiles(t *testing.T) {
|
||||
var _ CacheService = customCacheSvc{}
|
||||
}
|
||||
|
||||
// TestM10RedisNotReadyOnGetExists asserts the Redis-not-ready error is
|
||||
// surfaced (not swallowed) when no backend is configured.
|
||||
func TestM10RedisNotReadyOnGetExists(t *testing.T) {
|
||||
database.SetTestRedisClient(nil)
|
||||
t.Cleanup(func() { database.SetTestRedisClient(nil) })
|
||||
c := &redisCache{}
|
||||
ctx := context.Background()
|
||||
|
||||
if _, err := c.Get(ctx, "k", new(string)); !errors.Is(err, ErrRedisNotReady) {
|
||||
t.Fatalf("Get without Redis err = %v, want ErrRedisNotReady", err)
|
||||
}
|
||||
if _, err := c.Exists(ctx, "k"); !errors.Is(err, ErrRedisNotReady) {
|
||||
t.Fatalf("Exists without Redis err = %v, want ErrRedisNotReady", err)
|
||||
}
|
||||
}
|
||||
|
||||
Vendored
+3
-2
@@ -270,8 +270,9 @@ func SetWithPrefix(ctx context.Context, key string, value any, ttl time.Duration
|
||||
return GetCache().Set(ctx, kb.Build(key), value, ttl)
|
||||
}
|
||||
|
||||
// GetWithPrefix 带前缀的缓存获取
|
||||
func GetWithPrefix(ctx context.Context, key string, dest any, prefix string) bool {
|
||||
// GetWithPrefix 带前缀的缓存获取。命中返回 (true, nil);未命中返回 (false, nil);
|
||||
// Redis 错误或反序列化失败返回 (false, err)。
|
||||
func GetWithPrefix(ctx context.Context, key string, dest any, prefix string) (bool, error) {
|
||||
kb := NewKeyBuilder(WithPrefix(prefix))
|
||||
return GetCache().Get(ctx, kb.Build(key), dest)
|
||||
}
|
||||
|
||||
@@ -13,9 +13,12 @@ import (
|
||||
|
||||
"github.com/EthanCodeCraft/xlgo-core/config"
|
||||
"github.com/EthanCodeCraft/xlgo-core/database"
|
||||
"github.com/EthanCodeCraft/xlgo-core/jwt"
|
||||
"github.com/EthanCodeCraft/xlgo-core/validation"
|
||||
"github.com/alicebob/miniredis/v2"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/glebarez/sqlite"
|
||||
"github.com/redis/go-redis/v9"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
@@ -63,6 +66,15 @@ func setupExampleRouter(t *testing.T) (*gin.Engine, *gorm.DB) {
|
||||
}
|
||||
})
|
||||
|
||||
// JWT 黑名单走 Redis(ParseToken 默认 fail-closed,无 Redis 会拒绝所有 token)。
|
||||
// 测试用 miniredis 注入,避免依赖外部 Redis。
|
||||
mr := miniredis.RunT(t)
|
||||
redisClient := redis.NewClient(&redis.Options{Addr: mr.Addr()})
|
||||
t.Cleanup(func() { _ = redisClient.Close() })
|
||||
prevJWTMgr := jwt.GetDefaultJWT()
|
||||
jwt.SetDefaultJWTManager(jwt.NewJWTManagerWithRedis(redisClient))
|
||||
t.Cleanup(func() { jwt.SetDefaultJWTManager(prevJWTMgr) })
|
||||
|
||||
gin.SetMode(gin.TestMode)
|
||||
r := gin.New()
|
||||
registerRoutes(r.Group(""))
|
||||
|
||||
+13
-36
@@ -119,8 +119,8 @@ func (tb *TokenBlacklist) redisClient() *redis.Client {
|
||||
const blacklistOpTimeout = 1 * time.Second
|
||||
|
||||
// BlacklistPolicy 控制解析 Token 时遇到黑名单查询错误的处理策略。
|
||||
// ParseToken 为兼容未启用 Redis 的存量部署默认 fail-open;安全敏感路由应使用
|
||||
// ParseTokenFailClosed 或 ParseTokenWithBlacklistPolicy(..., BlacklistFailClosed)。
|
||||
// ParseToken 默认 BlacklistFailClosed(黑名单不可检查即拒绝);
|
||||
// 需显式 fail-open(仅无 Redis 或低安全场景)用 ParseTokenWithBlacklistPolicy(..., BlacklistFailOpen)。
|
||||
type BlacklistPolicy int
|
||||
|
||||
const (
|
||||
@@ -164,31 +164,10 @@ func (tb *TokenBlacklist) Add(jti string, expiry time.Time) error {
|
||||
return client.Set(ctx, key, "1", ttl).Err()
|
||||
}
|
||||
|
||||
// IsBlacklisted 检查 JTI 是否在黑名单中
|
||||
func (tb *TokenBlacklist) IsBlacklisted(jti string) bool {
|
||||
client := tb.redisClient()
|
||||
if client == nil {
|
||||
// Redis 未启用,不检查黑名单
|
||||
return false
|
||||
}
|
||||
|
||||
ctx, cancel := blacklistCtx()
|
||||
defer cancel()
|
||||
key := fmt.Sprintf("jwt_bl:%s", jti)
|
||||
// M-A 修复:显式处理 Redis 错误(原 .Val() 吞错致故障被静默当"未拉黑")。
|
||||
// 错误时保持 fail-open(返 false),与"无 Redis 部署可用"的固有局限一致,但记录告警
|
||||
// 便于运维感知 Redis 故障。安全敏感场景必须启用 Redis(见 ErrBlacklistUnavailable 注释)。
|
||||
n, err := client.Exists(ctx, key).Result()
|
||||
if err != nil {
|
||||
logger.Warn("jwt 黑名单检查失败,fail-open 放行", zap.String("jti", jti), zap.Error(err))
|
||||
return false
|
||||
}
|
||||
return n > 0
|
||||
}
|
||||
|
||||
// IsBlacklistedE 检查 JTI 是否在黑名单中,并返回 Redis/后端错误。
|
||||
// 当黑名单可用性属于路由安全契约时使用它;IsBlacklisted 保留旧版 fail-open bool API。
|
||||
func (tb *TokenBlacklist) IsBlacklistedE(jti string) (bool, error) {
|
||||
// IsBlacklisted 检查 JTI 是否在黑名单中。
|
||||
// 命中返回 (true, nil);未命中返回 (false, nil);
|
||||
// Redis 未启用或不可达返回 (false, ErrBlacklistUnavailable),由调用方决定 fail-open/fail-closed。
|
||||
func (tb *TokenBlacklist) IsBlacklisted(jti string) (bool, error) {
|
||||
client := tb.redisClient()
|
||||
if client == nil {
|
||||
return false, ErrBlacklistUnavailable
|
||||
@@ -393,7 +372,7 @@ func checkTokenBlacklist(claims *Claims, policy BlacklistPolicy) error {
|
||||
if claims == nil || claims.JTI == "" {
|
||||
return nil
|
||||
}
|
||||
revoked, err := currentBlacklist().IsBlacklistedE(claims.JTI)
|
||||
revoked, err := currentBlacklist().IsBlacklisted(claims.JTI)
|
||||
if err != nil {
|
||||
if policy == BlacklistFailClosed {
|
||||
return err
|
||||
@@ -407,13 +386,10 @@ func checkTokenBlacklist(claims *Claims, policy BlacklistPolicy) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// ParseToken 解析 JWT Token
|
||||
// ParseToken 解析 JWT Token。默认 fail-closed:黑名单后端不可检查时返回
|
||||
// ErrBlacklistUnavailable,拒绝该 Token。需要显式 fail-open(仅无 Redis 或低安全场景)
|
||||
// 请用 ParseTokenWithBlacklistPolicy(token, BlacklistFailOpen)。
|
||||
func ParseToken(tokenString string) (*Claims, error) {
|
||||
return ParseTokenWithBlacklistPolicy(tokenString, BlacklistFailOpen)
|
||||
}
|
||||
|
||||
// ParseTokenFailClosed 解析 JWT Token;若黑名单后端不可检查,则拒绝该 Token。
|
||||
func ParseTokenFailClosed(tokenString string) (*Claims, error) {
|
||||
return ParseTokenWithBlacklistPolicy(tokenString, BlacklistFailClosed)
|
||||
}
|
||||
|
||||
@@ -521,8 +497,9 @@ func GetJTI(tokenString string) (string, error) {
|
||||
return "", ErrTokenInvalid
|
||||
}
|
||||
|
||||
// IsTokenRevoked 检查 Token 是否被撤销(通过 JTI)
|
||||
func IsTokenRevoked(jti string) bool {
|
||||
// IsTokenRevoked 检查 Token 是否被撤销(通过 JTI)。
|
||||
// 返回 (是否撤销, 错误);黑名单后端不可用时返回 (false, ErrBlacklistUnavailable)。
|
||||
func IsTokenRevoked(jti string) (bool, error) {
|
||||
return currentBlacklist().IsBlacklisted(jti)
|
||||
}
|
||||
|
||||
|
||||
@@ -74,7 +74,7 @@ func TestC9cConcurrentSetDefaultAndRead(t *testing.T) {
|
||||
default:
|
||||
}
|
||||
_, _ = ParseToken(token)
|
||||
_ = IsTokenRevoked("some-jti")
|
||||
_, _ = IsTokenRevoked("some-jti")
|
||||
_ = InvalidateTokenByID("some-jti", time.Now().Add(time.Hour))
|
||||
_ = currentManager()
|
||||
}
|
||||
|
||||
+10
-7
@@ -44,6 +44,7 @@ func TestGenerateToken(t *testing.T) {
|
||||
|
||||
func TestParseToken(t *testing.T) {
|
||||
setupTestConfig()
|
||||
setupMiniRedis(t)
|
||||
|
||||
// 先生成 token
|
||||
token, _ := jwt.GenerateToken(1, "testuser", "admin", "super_admin")
|
||||
@@ -183,9 +184,10 @@ func TestTokenBlacklist(t *testing.T) {
|
||||
t.Errorf("TokenBlacklist.Add without Redis should return ErrBlacklistUnavailable, got %v", err)
|
||||
}
|
||||
|
||||
// 无 Redis 时,IsBlacklisted 仍返回 false(验证侧 fail-open 是无 Redis 部署的固有局限)
|
||||
if tb.IsBlacklisted("test-token") {
|
||||
t.Error("TokenBlacklist.IsBlacklisted without Redis should return false")
|
||||
// 无 Redis 时,IsBlacklisted 返回 (false, ErrBlacklistUnavailable)(fail-closed:错误上抛)
|
||||
revoked, err := tb.IsBlacklisted("test-token")
|
||||
if revoked || !errors.Is(err, jwt.ErrBlacklistUnavailable) {
|
||||
t.Errorf("TokenBlacklist.IsBlacklisted without Redis = (%v, %v), want (false, ErrBlacklistUnavailable)", revoked, err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -376,6 +378,7 @@ func TestSupportedAlgorithmHS384(t *testing.T) {
|
||||
Expire: time.Hour,
|
||||
}})
|
||||
t.Cleanup(setupTestConfig)
|
||||
setupMiniRedis(t)
|
||||
|
||||
token, err := jwt.GenerateToken(1, "u", "admin", "admin")
|
||||
if err != nil {
|
||||
@@ -529,11 +532,11 @@ func TestParseTokenBlacklistPolicy(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateToken: %v", err)
|
||||
}
|
||||
if _, err := jwt.ParseToken(token); err != nil {
|
||||
t.Fatalf("ParseToken default fail-open should pass without Redis: %v", err)
|
||||
if _, err := jwt.ParseToken(token); !errors.Is(err, jwt.ErrBlacklistUnavailable) {
|
||||
t.Fatalf("ParseToken default fail-closed should reject without Redis, err = %v, want ErrBlacklistUnavailable", err)
|
||||
}
|
||||
if _, err := jwt.ParseTokenFailClosed(token); !errors.Is(err, jwt.ErrBlacklistUnavailable) {
|
||||
t.Fatalf("ParseTokenFailClosed err = %v, want ErrBlacklistUnavailable", err)
|
||||
if _, err := jwt.ParseTokenWithBlacklistPolicy(token, jwt.BlacklistFailOpen); err != nil {
|
||||
t.Fatalf("ParseTokenWithBlacklistPolicy fail-open should pass without Redis: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -42,6 +42,7 @@ func setAuthUser(userType, role string) func(*gin.Context) {
|
||||
}
|
||||
|
||||
func TestAuthRequiredAcceptsCaseInsensitiveBearer_M8(t *testing.T) {
|
||||
setupMiddlewareMiniRedis(t)
|
||||
if err := config.Set(&config.Config{
|
||||
JWT: config.JWTConfig{
|
||||
Secret: "test-secret-key-1234567890123456789012",
|
||||
|
||||
@@ -1115,7 +1115,7 @@ func TestRateLimiterRejectsInvalidConfig(t *testing.T) {
|
||||
_ = middleware.NewRedisRateLimiter("bad", 0, time.Minute)
|
||||
})
|
||||
assertPanic(t, "redis fail-closed limiter zero window", func() {
|
||||
_ = middleware.NewRedisRateLimiterFailClosed("bad", 1, 0)
|
||||
_ = middleware.NewRedisRateLimiter("bad", 1, 0, middleware.WithFailClosed(true))
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1371,7 +1371,7 @@ func TestRedisRateLimiterFailClosedNoRedis(t *testing.T) {
|
||||
prev := database.SetTestRedisClient(nil)
|
||||
defer func() { database.SetTestRedisClient(prev) }()
|
||||
|
||||
limiter := middleware.NewRedisRateLimiterFailClosed("test", 10, time.Minute)
|
||||
limiter := middleware.NewRedisRateLimiter("test", 10, time.Minute, middleware.WithFailClosed(true))
|
||||
allowed, err := limiter.Allow(context.Background(), "1.2.3.4")
|
||||
if allowed {
|
||||
t.Error("fail-closed no-redis should deny")
|
||||
@@ -1397,7 +1397,7 @@ func TestRedisRateLimiterFailClosedOnRedisError(t *testing.T) {
|
||||
}
|
||||
|
||||
// fail-closed:正常时放行。
|
||||
closedLimiter := middleware.NewRedisRateLimiterFailClosed("test_closed", 10, time.Minute)
|
||||
closedLimiter := middleware.NewRedisRateLimiter("test_closed", 10, time.Minute, middleware.WithFailClosed(true))
|
||||
allowed, err = closedLimiter.Allow(context.Background(), "1.2.3.4")
|
||||
if err != nil {
|
||||
t.Fatalf("fail-closed normal err = %v", err)
|
||||
@@ -1431,7 +1431,7 @@ func TestRedisRateLimitFailClosedMiddlewareReturns503(t *testing.T) {
|
||||
defer func() { database.SetTestRedisClient(prev) }()
|
||||
|
||||
r := setupTestRouter()
|
||||
r.Use(middleware.RedisRateLimitFailClosed("login_limit", 10))
|
||||
r.Use(middleware.RedisRateLimit("login_limit", 10, middleware.WithFailClosed(true)))
|
||||
r.GET("/login", func(c *gin.Context) { c.JSON(200, gin.H{"ok": true}) })
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
+36
-52
@@ -193,28 +193,32 @@ else
|
||||
end
|
||||
`
|
||||
|
||||
// NewRedisRateLimiter 创建 Redis 分布式限流器(默认 fail-open:Redis 错误时放行,避免影响业务)。
|
||||
// 安全敏感场景(如登录防爆破)应使用 NewRedisRateLimiterFailClosed,Redis 故障时拒绝以防限流失效。
|
||||
func NewRedisRateLimiter(keyPrefix string, rate int, window time.Duration) *RedisRateLimiter {
|
||||
mustValidRateLimit(rate, window)
|
||||
return &RedisRateLimiter{
|
||||
keyPrefix: keyPrefix,
|
||||
rate: rate,
|
||||
window: window,
|
||||
// failClosed 零值 false(fail-open,兼容默认)
|
||||
// RedisRateLimiterOption 配置 RedisRateLimiter 的可选策略。
|
||||
type RedisRateLimiterOption func(*RedisRateLimiter)
|
||||
|
||||
// WithFailClosed 设置 Redis 故障时 fail-closed(拒绝请求)。
|
||||
// 不传或传 false 为 fail-open(放行,兼容默认)。安全敏感场景(登录防爆破等)应传 true。
|
||||
func WithFailClosed(failClosed bool) RedisRateLimiterOption {
|
||||
return func(rl *RedisRateLimiter) {
|
||||
rl.failClosed.Store(failClosed)
|
||||
}
|
||||
}
|
||||
|
||||
// NewRedisRateLimiterFailClosed 创建安全型 Redis 分布式限流器(fail-closed):
|
||||
// Redis 不可用/错误/返回非预期类型时拒绝请求,避免限流静默失效(防爆破场景必备)。
|
||||
func NewRedisRateLimiterFailClosed(keyPrefix string, rate int, window time.Duration) *RedisRateLimiter {
|
||||
// NewRedisRateLimiter 创建 Redis 分布式限流器。
|
||||
// 默认 fail-open(Redis 故障时放行,避免影响业务);安全敏感场景传 WithFailClosed(true)。
|
||||
func NewRedisRateLimiter(keyPrefix string, rate int, window time.Duration, opts ...RedisRateLimiterOption) *RedisRateLimiter {
|
||||
mustValidRateLimit(rate, window)
|
||||
rl := &RedisRateLimiter{
|
||||
keyPrefix: keyPrefix,
|
||||
rate: rate,
|
||||
window: window,
|
||||
// failClosed 零值 false(fail-open,兼容默认)
|
||||
}
|
||||
for _, opt := range opts {
|
||||
if opt != nil {
|
||||
opt(rl)
|
||||
}
|
||||
}
|
||||
rl.failClosed.Store(true)
|
||||
return rl
|
||||
}
|
||||
|
||||
@@ -458,11 +462,11 @@ func redisLimitDecision(c *gin.Context, allowed bool, err error) {
|
||||
c.Next()
|
||||
}
|
||||
|
||||
// RedisRateLimit Redis 分布式限流中间件(fail-open:Redis 故障时放行,避免影响业务)。
|
||||
// 参数: keyPrefix 键名前缀(如 "login_limit"),rate 每分钟请求数
|
||||
// 安全敏感场景(登录防爆破等)应使用 RedisRateLimitFailClosed,Redis 故障时拒绝以防限流失效。
|
||||
func RedisRateLimit(keyPrefix string, rate int) gin.HandlerFunc {
|
||||
limiter := NewRedisRateLimiter(keyPrefix, rate, time.Minute)
|
||||
// RedisRateLimit Redis 分布式限流中间件。
|
||||
// 默认 fail-open(Redis 故障时放行,避免影响业务);安全敏感场景(登录防爆破等)
|
||||
// 传 WithFailClosed(true),Redis 故障时拒绝以防限流失效。
|
||||
func RedisRateLimit(keyPrefix string, rate int, opts ...RedisRateLimiterOption) gin.HandlerFunc {
|
||||
limiter := NewRedisRateLimiter(keyPrefix, rate, time.Minute, opts...)
|
||||
|
||||
return func(c *gin.Context) {
|
||||
identifier := c.ClientIP()
|
||||
@@ -478,22 +482,11 @@ func RedisRateLimit(keyPrefix string, rate int) gin.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
// RedisRateLimitFailClosed 安全型 Redis 分布式限流中间件(fail-closed):
|
||||
// Redis 故障时拒绝(503),避免限流静默失效。用于登录防爆破等安全场景。
|
||||
func RedisRateLimitFailClosed(keyPrefix string, rate int) gin.HandlerFunc {
|
||||
limiter := NewRedisRateLimiterFailClosed(keyPrefix, rate, time.Minute)
|
||||
|
||||
return func(c *gin.Context) {
|
||||
identifier := c.ClientIP()
|
||||
allowed, err := limiter.Allow(c.Request.Context(), identifier)
|
||||
redisLimitDecision(c, allowed, err)
|
||||
}
|
||||
}
|
||||
|
||||
// RedisRateLimitWithIdentifier 自定义标识的 Redis 分布式限流(fail-open)。
|
||||
// 参数: keyPrefix 键名前缀,rate 每分钟请求数,identifierFunc 标识获取函数
|
||||
func RedisRateLimitWithIdentifier(keyPrefix string, rate int, identifierFunc func(c *gin.Context) string) gin.HandlerFunc {
|
||||
limiter := NewRedisRateLimiter(keyPrefix, rate, time.Minute)
|
||||
// RedisRateLimitWithIdentifier 自定义标识的 Redis 分布式限流。
|
||||
// 参数: keyPrefix 键名前缀,rate 1 分钟窗口内允许的请求数,identifierFunc 标识获取函数。
|
||||
// identifierFunc 为 nil 或返回空串时回退到 c.ClientIP()。默认 fail-open,传 WithFailClosed(true) 切换。
|
||||
func RedisRateLimitWithIdentifier(keyPrefix string, rate int, identifierFunc func(c *gin.Context) string, opts ...RedisRateLimiterOption) gin.HandlerFunc {
|
||||
limiter := NewRedisRateLimiter(keyPrefix, rate, time.Minute, opts...)
|
||||
|
||||
return func(c *gin.Context) {
|
||||
identifier := ""
|
||||
@@ -511,10 +504,10 @@ func RedisRateLimitWithIdentifier(keyPrefix string, rate int, identifierFunc fun
|
||||
|
||||
// LoginRedisRateLimit 登录接口 Redis 分布式限流(fail-closed)。
|
||||
//
|
||||
// H4c: 登录防爆破场景必须 fail-closed——Redis 故障时若 fail-open 则限流失效、
|
||||
// H4c: 登录防爆破场景必须 fail-closed--Redis 故障时若 fail-open 则限流失效、
|
||||
// 攻击者可借 Redis 抖动窗口无限爆破。改为 fail-closed:Redis 故障时返 503 拒绝。
|
||||
func LoginRedisRateLimit() gin.HandlerFunc {
|
||||
return RedisRateLimitFailClosed("login_limit", 10)
|
||||
return RedisRateLimit("login_limit", 10, WithFailClosed(true))
|
||||
}
|
||||
|
||||
// APIRedisRateLimit API Redis 分布式限流(fail-open,避免影响业务)。
|
||||
@@ -522,25 +515,16 @@ func APIRedisRateLimit() gin.HandlerFunc {
|
||||
return RedisRateLimit("api_limit", 100)
|
||||
}
|
||||
|
||||
// UploadRedisRateLimit 上传接口 Redis 分布式限流(fail-open)。
|
||||
// UploadRedisRateLimit 上传接口 Redis 分布式限流(fail-closed)。
|
||||
// 上传属资源敏感操作,Redis 故障时拒绝以防限流静默失效。
|
||||
func UploadRedisRateLimit() gin.HandlerFunc {
|
||||
return RedisRateLimit("upload_limit", 20)
|
||||
return RedisRateLimit("upload_limit", 20, WithFailClosed(true))
|
||||
}
|
||||
|
||||
// CustomRedisRateLimit 自定义 Redis 分布式限流(fail-open)。
|
||||
func CustomRedisRateLimit(keyPrefix string, rate int, window time.Duration) gin.HandlerFunc {
|
||||
limiter := NewRedisRateLimiter(keyPrefix, rate, window)
|
||||
|
||||
return func(c *gin.Context) {
|
||||
identifier := c.ClientIP()
|
||||
allowed, err := limiter.Allow(c.Request.Context(), identifier)
|
||||
redisLimitDecision(c, allowed, err)
|
||||
}
|
||||
}
|
||||
|
||||
// CustomRedisRateLimitFailClosed 自定义安全型 Redis 分布式限流(fail-closed)。
|
||||
func CustomRedisRateLimitFailClosed(keyPrefix string, rate int, window time.Duration) gin.HandlerFunc {
|
||||
limiter := NewRedisRateLimiterFailClosed(keyPrefix, rate, window)
|
||||
// CustomRedisRateLimit 自定义 Redis 分布式限流。
|
||||
// 默认 fail-open,传 WithFailClosed(true) 切换为 fail-closed。
|
||||
func CustomRedisRateLimit(keyPrefix string, rate int, window time.Duration, opts ...RedisRateLimiterOption) gin.HandlerFunc {
|
||||
limiter := NewRedisRateLimiter(keyPrefix, rate, window, opts...)
|
||||
|
||||
return func(c *gin.Context) {
|
||||
identifier := c.ClientIP()
|
||||
|
||||
+36
-16
@@ -30,7 +30,9 @@ type Storage interface {
|
||||
GetURL(path string) string
|
||||
Delete(path string) error
|
||||
Get(path string) ([]byte, error)
|
||||
Exists(path string) bool
|
||||
// Exists 检查文件是否存在。存在返回 (true, nil);不存在返回 (false, nil);
|
||||
// 未初始化、路径非法/穿越、后端错误返回 (false, err)。
|
||||
Exists(path string) (bool, error)
|
||||
}
|
||||
|
||||
var (
|
||||
@@ -46,6 +48,9 @@ var (
|
||||
// ErrUploadTooLarge 上传声明大小或实际字节数超过 MaxSizeBytes(P0)。客户端声明的 file.Size
|
||||
// 不可信,故除前置校验外,拷贝阶段也按实际字节封顶,防止声明小体积却流式发送大 body 撑爆磁盘/OSS。
|
||||
ErrUploadTooLarge = errors.New("upload exceeds max size")
|
||||
// ErrReadTooLarge Get 读取内容超过 maxReadBytes 上限(C4c)。与 ErrUploadTooLarge 区分:
|
||||
// 前者是读操作的超限,后者是上传操作的超限。
|
||||
ErrReadTooLarge = errors.New("read exceeds max size")
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -458,19 +463,25 @@ func (s *LocalStorage) Get(p string) ([]byte, error) {
|
||||
return nil, fmt.Errorf("读取文件内容失败: %w", err)
|
||||
}
|
||||
if s.maxReadBytes > 0 && int64(len(data)) > s.maxReadBytes {
|
||||
return nil, fmt.Errorf("文件超过最大读取限制 %d 字节: %w", s.maxReadBytes, ErrInvalidPath)
|
||||
return nil, fmt.Errorf("文件超过最大读取限制 %d 字节: %w", s.maxReadBytes, ErrReadTooLarge)
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
// Exists 检查文件是否存在
|
||||
func (s *LocalStorage) Exists(p string) bool {
|
||||
// Exists 检查文件是否存在。存在返回 (true, nil);不存在返回 (false, nil);
|
||||
// 路径非法/穿越返回 (false, err);其他 Stat 错误返回 (false, err)。
|
||||
func (s *LocalStorage) Exists(p string) (bool, error) {
|
||||
fullPath, err := s.safeJoin(p)
|
||||
if err != nil {
|
||||
return false
|
||||
return false, err
|
||||
}
|
||||
_, err = os.Stat(fullPath)
|
||||
return err == nil
|
||||
if _, err := os.Stat(fullPath); err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return false, nil
|
||||
}
|
||||
return false, err
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// OSSStorage OSS 存储
|
||||
@@ -661,19 +672,27 @@ func (s *OSSStorage) Get(p string) ([]byte, error) {
|
||||
return nil, fmt.Errorf("读取 OSS 文件内容失败: %w", err)
|
||||
}
|
||||
if s.maxReadBytes > 0 && int64(len(data)) > s.maxReadBytes {
|
||||
return nil, fmt.Errorf("文件超过最大读取限制 %d 字节: %w", s.maxReadBytes, ErrInvalidPath)
|
||||
return nil, fmt.Errorf("文件超过最大读取限制 %d 字节: %w", s.maxReadBytes, ErrReadTooLarge)
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
// Exists 检查 OSS 文件是否存在
|
||||
func (s *OSSStorage) Exists(p string) bool {
|
||||
// Exists 检查 OSS 文件是否存在。存在返回 (true, nil);object 不存在返回 (false, nil);
|
||||
// key 非法/穿越返回 (false, err);鉴权/网络/服务端错误返回 (false, err)。
|
||||
func (s *OSSStorage) Exists(p string) (bool, error) {
|
||||
key, err := sanitizeObjectKey(p)
|
||||
if err != nil {
|
||||
return false
|
||||
return false, err
|
||||
}
|
||||
_, err = s.bucket.GetObjectMeta(key)
|
||||
return err == nil
|
||||
if _, err := s.bucket.GetObjectMeta(key); err != nil {
|
||||
// OSS object 不存在(404 / NoSuchKey)不是错误,返回 (false, nil)。
|
||||
var se *oss.ServiceError
|
||||
if errors.As(err, &se) && (se.StatusCode == http.StatusNotFound || se.Code == "NoSuchKey") {
|
||||
return false, nil
|
||||
}
|
||||
return false, err
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// StorageManager 存储管理器(#10)。照 database.Manager 模式:
|
||||
@@ -811,11 +830,12 @@ func Get(path string) ([]byte, error) {
|
||||
return s.Get(path)
|
||||
}
|
||||
|
||||
// Exists 检查文件是否存在
|
||||
func Exists(path string) bool {
|
||||
// Exists 检查文件是否存在。未初始化返回 (false, ErrStorageNotInitialized);
|
||||
// 其余语义同 Storage.Exists。
|
||||
func Exists(path string) (bool, error) {
|
||||
s := GetStorage()
|
||||
if s == nil {
|
||||
return false
|
||||
return false, ErrStorageNotInitialized
|
||||
}
|
||||
return s.Exists(path)
|
||||
}
|
||||
|
||||
@@ -84,9 +84,9 @@ func TestLocalStoragePathTraversal(t *testing.T) {
|
||||
t.Errorf("Get(%q) err = %v, want ErrPathTraversal", escapeRel, err)
|
||||
}
|
||||
|
||||
// Exists 必须返回 false(而非穿越探测到 canary)
|
||||
if s.Exists(escapeRel) {
|
||||
t.Errorf("Exists(%q) = true, want false (traversal must not probe outside root)", escapeRel)
|
||||
// Exists 必须返回 (false, ErrPathTraversal)(而非穿越探测到 canary)
|
||||
if ok, err := s.Exists(escapeRel); ok || !errors.Is(err, storage.ErrPathTraversal) {
|
||||
t.Errorf("Exists(%q) = (%v, %v), want (false, ErrPathTraversal)", escapeRel, ok, err)
|
||||
}
|
||||
|
||||
// 绝对路径也必须拒绝
|
||||
@@ -111,8 +111,8 @@ func TestLocalStorageNormalPathStillWorks(t *testing.T) {
|
||||
}
|
||||
|
||||
rel := filepath.ToSlash(filepath.Join("sub", "ok.txt"))
|
||||
if !s.Exists(rel) {
|
||||
t.Error("Exists(normal) = false, want true")
|
||||
if ok, err := s.Exists(rel); err != nil || !ok {
|
||||
t.Errorf("Exists(normal) = (%v, %v), want (true, nil)", ok, err)
|
||||
}
|
||||
data, err := s.Get(rel)
|
||||
if err != nil {
|
||||
@@ -170,8 +170,8 @@ func TestLocalStorageGetReadLimit(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("Upload big file: %v", err)
|
||||
}
|
||||
if _, err := s.Get(rel); !errors.Is(err, storage.ErrInvalidPath) {
|
||||
t.Errorf("Get over-limit err = %v, want ErrInvalidPath", err)
|
||||
if _, err := s.Get(rel); !errors.Is(err, storage.ErrReadTooLarge) {
|
||||
t.Errorf("Get over-limit err = %v, want ErrReadTooLarge", err)
|
||||
}
|
||||
|
||||
// 小文件应正常读取。
|
||||
|
||||
@@ -40,8 +40,8 @@ func TestStorageNotInitialized(t *testing.T) {
|
||||
if url := storage.GetURL("missing"); url != "" {
|
||||
t.Fatalf("expected empty URL, got %q", url)
|
||||
}
|
||||
if storage.Exists("missing") {
|
||||
t.Fatal("expected Exists false without storage")
|
||||
if ok, err := storage.Exists("missing"); ok || !errors.Is(err, storage.ErrStorageNotInitialized) {
|
||||
t.Fatalf("expected (false, ErrStorageNotInitialized) without storage, got (%v, %v)", ok, err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -107,8 +107,9 @@ func TestLocalStorageDeleteGetExists(t *testing.T) {
|
||||
}
|
||||
|
||||
// 测试 Exists
|
||||
if !local.Exists("test.txt") {
|
||||
t.Error("Exists should return true for existing file")
|
||||
ok, err := local.Exists("test.txt")
|
||||
if err != nil || !ok {
|
||||
t.Errorf("Exists should return (true, nil) for existing file, got (%v, %v)", ok, err)
|
||||
}
|
||||
|
||||
// 测试 Get
|
||||
@@ -127,8 +128,9 @@ func TestLocalStorageDeleteGetExists(t *testing.T) {
|
||||
}
|
||||
|
||||
// 验证删除后不存在
|
||||
if local.Exists("test.txt") {
|
||||
t.Error("Exists should return false after delete")
|
||||
ok, err = local.Exists("test.txt")
|
||||
if err != nil || ok {
|
||||
t.Errorf("Exists should return (false, nil) after delete, got (%v, %v)", ok, err)
|
||||
}
|
||||
|
||||
// 删除不存在的文件应该失败
|
||||
|
||||
Reference in New Issue
Block a user