Merge branch 'refactor/consolidate-failable-apis'

This commit is contained in:
杭州明婳科技
2026-07-12 22:23:41 +08:00
16 changed files with 256 additions and 255 deletions
+10
View File
@@ -49,6 +49,16 @@ xlgo 框架更新日志。本文档遵循 [Keep a Changelog](https://keepachange
- **`cron.ParseCron` 非法表达式改为 fail-fast panic**cron/cron.goM13):旧行为会把非法表达式静默回退为每分钟执行,容易让拼写错误变成高频任务。动态输入请用 `ParseCronStrict` 处理 error;确实需要旧回退语义时改用新增 `ParseCronOrDefault`
- **repository 查询保护默认开启**repository/repository.goM5/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/NoSuchKeynot 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,不静默回退明文。
+28 -16
View File
@@ -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-openRedis 故障时放行)
r.Use(middleware.LoginRedisRateLimit()) // 登录限流(fail-closedRedis 故障时拒绝,防爆破)
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-closedH4c**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 {
// 文件存在
}
```
+12 -14
View File
@@ -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 流式响应
+19 -71
View File
@@ -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)
}
+56 -23
View File
@@ -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)
}
}
+3 -2
View File
@@ -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)
}
+12
View File
@@ -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 黑名单走 RedisParseToken 默认 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
View File
@@ -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)
}
+1 -1
View File
@@ -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
View File
@@ -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)
}
}
+1
View File
@@ -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",
+4 -4
View File
@@ -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
View File
@@ -193,28 +193,32 @@ else
end
`
// NewRedisRateLimiter 创建 Redis 分布式限流器(默认 fail-open:Redis 错误时放行,避免影响业务)
// 安全敏感场景(如登录防爆破)应使用 NewRedisRateLimiterFailClosedRedis 故障时拒绝以防限流失效。
func NewRedisRateLimiter(keyPrefix string, rate int, window time.Duration) *RedisRateLimiter {
mustValidRateLimit(rate, window)
return &RedisRateLimiter{
keyPrefix: keyPrefix,
rate: rate,
window: window,
// failClosed 零值 falsefail-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 零值 falsefail-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 每分钟请求数
// 安全敏感场景(登录防爆破等)应使用 RedisRateLimitFailClosedRedis 故障时拒绝以防限流失效。
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-closedRedis 故障时返 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
View File
@@ -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 上传声明大小或实际字节数超过 MaxSizeBytesP0)。客户端声明的 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)
}
+7 -7
View File
@@ -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)
}
// 小文件应正常读取。
+8 -6
View File
@@ -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)
}
// 删除不存在的文件应该失败