소스 검색

feat(redis): T4-1 infra/redis 装配 + token 存储实现

- client.go:NewClient(cfg.Redis) 建 go-redis v9 连接池 + Ping fail-fast(1.5s 超时);
  Client 薄封装 *gredis.Client(嵌入提升全部命令方法),Close 幂等(nil 接收者安全)
- refresh_store.go:RefreshStore 实现 token.RefreshStore 接口
  · 键 refresh:{jti}(前缀硬编码,与吊销前缀刻意分离防运维误清)
  · GetRefresh 键不存在返回 (nil,nil)(防枚举 jti 存在性);gredis.Nil 区分于故障
- revocation_store.go:RevocationStore 实现 token.RevocationStore 接口
  · 键 {revocationKeyPrefix}:{jti}(前缀走配置,默认 revoked)
  · IsRevoked 用 EXISTS(少一次 payload 读);Redis 故障 fail-closed 返回 error(安全红线)
  · AddRevoked ttl<=0 跳过写(token 本已过期,省键)
- store_test.go:12 单测(miniredis 嵌入式,test-only 依赖)覆盖往返/键不存在/TTL/幂等删/
  fail-closed 故障路径/默认前缀;TestRevocationStore_FailClosed 验证 Redis 故障时 IsRevoked
  必须返回 error(绝不静默放行)
- go.mod:引入 github.com/redis/go-redis/v9 v9.11.0 + github.com/alicebob/miniredis/v2 v2.34.0
Q 3 일 전
부모
커밋
024fa155bd
6개의 변경된 파일과 554개의 추가작업 그리고 0개의 파일을 삭제
  1. 6 0
      go.mod
  2. 16 0
      go.sum
  3. 102 0
      internal/infra/redis/client.go
  4. 90 0
      internal/infra/redis/refresh_store.go
  5. 104 0
      internal/infra/redis/revocation_store.go
  6. 236 0
      internal/infra/redis/store_test.go

+ 6 - 0
go.mod

@@ -3,10 +3,12 @@ module yuansiwei/unified-backend
 go 1.27
 
 require (
+	github.com/alicebob/miniredis/v2 v2.34.0
 	github.com/go-playground/validator/v10 v10.27.0
 	github.com/gofiber/fiber/v2 v2.52.16
 	github.com/golang-jwt/jwt/v5 v5.3.1
 	github.com/oklog/ulid v1.3.1
+	github.com/redis/go-redis/v9 v9.11.0
 	go.uber.org/zap v1.28.0
 	golang.org/x/crypto v0.33.0
 	gorm.io/driver/mysql v1.6.0
@@ -15,7 +17,10 @@ require (
 
 require (
 	filippo.io/edwards25519 v1.1.0 // indirect
+	github.com/alicebob/gopher-json v0.0.0-20230218143504-906a9b012302 // indirect
 	github.com/andybalholm/brotli v1.1.0 // indirect
+	github.com/cespare/xxhash/v2 v2.3.0 // indirect
+	github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
 	github.com/gabriel-vasile/mimetype v1.4.8 // indirect
 	github.com/go-playground/locales v0.14.1 // indirect
 	github.com/go-playground/universal-translator v0.18.1 // indirect
@@ -32,6 +37,7 @@ require (
 	github.com/valyala/bytebufferpool v1.0.0 // indirect
 	github.com/valyala/fasthttp v1.51.0 // indirect
 	github.com/valyala/tcplisten v1.0.0 // indirect
+	github.com/yuin/gopher-lua v1.1.1 // indirect
 	go.uber.org/multierr v1.10.0 // indirect
 	golang.org/x/net v0.34.0 // indirect
 	golang.org/x/sys v0.30.0 // indirect

+ 16 - 0
go.sum

@@ -1,9 +1,21 @@
 filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
 filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
+github.com/alicebob/gopher-json v0.0.0-20230218143504-906a9b012302 h1:uvdUDbHQHO85qeSydJtItA4T55Pw6BtAejd0APRJOCE=
+github.com/alicebob/gopher-json v0.0.0-20230218143504-906a9b012302/go.mod h1:SGnFV6hVsYE877CKEZ6tDNTjaSXYUk6QqoIK6PrAtcc=
+github.com/alicebob/miniredis/v2 v2.34.0 h1:mBFWMaJSNL9RwdGRyEDoAAv8OQc5UlEhLDQggTglU/0=
+github.com/alicebob/miniredis/v2 v2.34.0/go.mod h1:kWShP4b58T1CW0Y5dViCd5ztzrDqRWqM3nksiyXk5s8=
 github.com/andybalholm/brotli v1.1.0 h1:eLKJA0d02Lf0mVpIDgYnqXcUn0GqVmEFny3VuID1U3M=
 github.com/andybalholm/brotli v1.1.0/go.mod h1:sms7XGricyQI9K10gOSf56VKKWS4oLer58Q+mhRPtnY=
+github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
+github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
+github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
+github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
+github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
+github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
 github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
 github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
+github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
 github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM=
 github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8=
 github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
@@ -43,6 +55,8 @@ github.com/oklog/ulid v1.3.1 h1:EGfNDEx6MqHz8B3uNV6QAib1UR2Lm97sHi3ocA6ESJ4=
 github.com/oklog/ulid v1.3.1/go.mod h1:CirwcVhetQ6Lv90oh/F+FBtV6XMibvdAFo93nm5qn4U=
 github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
 github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/redis/go-redis/v9 v9.11.0 h1:E3S08Gl/nJNn5vkxd2i78wZxWAPNZgUNTp8WIJUAiIs=
+github.com/redis/go-redis/v9 v9.11.0/go.mod h1:huWgSWd8mW6+m0VPhJjSSQ+d6Nh1VICQ6Q5lHuCH/Iw=
 github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY=
 github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
 github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
@@ -53,6 +67,8 @@ github.com/valyala/fasthttp v1.51.0 h1:8b30A5JlZ6C7AS81RsWjYMQmrZG6feChmgAolCl1S
 github.com/valyala/fasthttp v1.51.0/go.mod h1:oI2XroL+lI7vdXyYoQk03bXBThfFl2cVdIA3Xl7cH8g=
 github.com/valyala/tcplisten v1.0.0 h1:rBHj/Xf+E1tRGZyWIWwJDiRY0zc1Js+CV5DqwacVSA8=
 github.com/valyala/tcplisten v1.0.0/go.mod h1:T0xQ8SeCZGxckz9qRXTfG43PvQ/mcWh7FwZEA7Ioqkc=
+github.com/yuin/gopher-lua v1.1.1 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M=
+github.com/yuin/gopher-lua v1.1.1/go.mod h1:GBR0iDaNXjAgGg9zfCvksxSRnQx76gclCIb7kdAd1Pw=
 go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
 go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
 go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ=

+ 102 - 0
internal/infra/redis/client.go

@@ -0,0 +1,102 @@
+// Package redis Redis 连接装配(缓存/锁/任务表并发控制 + token 存储,ADR-008 配套)。
+//
+// 职责边界(与 infra/db 对称):
+//   - 本包只负责"连接池建立 + 健康检查 + 提供 Client 句柄",**不实现任何 token 业务键**;
+//     refresh 白名单 / 吊销黑名单的存储实现见同包 refresh_store.go / revocation_store.go,
+//     它们满足 token 包定义的 RefreshStore / RevocationStore 接口(接口在 identity 侧定义,
+//     实现落 infra——依赖方向 identity → infra,infra 不 import identity,见 token 包注释)。
+//   - 敏感配置(addr 带密码时)走环境变量(config.RedisCfg 注释),不入库。
+//
+// 命名说明:本包包名是 redis,为避免与 go-redis(github.com/redis/go-redis/v9,包名亦 redis)
+// 冲突,本包内一律用别名 gredis 指代 go-redis。
+//
+// 典型使用(cmd/api/main.go 装配段):
+//
+//	rc, err := redis.NewClient(cfg.Redis)
+//	if err != nil { logger.Fatal("redis: %v", err) } // 启动即不可用 = fail-fast(token 链路强依赖)
+//	defer func() { _ = rc.Close() }()
+//
+//	// 构造 token 签发器/验签器时传入两个 store(满足 token.RefreshStore / token.RevocationStore 接口)
+//	iss, err := token.NewIssuer(cfg.Auth.PrivateKeyPEM, cfg.Auth.Issuer,
+//		accessTTL, refreshTTL, redis.NewRefreshStore(rc), redis.NewRevocationStore(rc, cfg.Auth.RevocationKeyPrefix))
+package redis
+
+import (
+	"context"
+	"fmt"
+	"time"
+
+	gredis "github.com/redis/go-redis/v9"
+
+	"yuansiwei/unified-backend/internal/infra/config"
+)
+
+// pingTimeout NewClient 内 Ping 健康检查的超时(1.5s:本地 Redis 毫秒级响应,
+// 1.5s 足够覆盖慢启动/高负载;超时必须视为"不可用",不允许带病启动)。
+const pingTimeout = 1500 * time.Millisecond
+
+// defaultPoolSize cfg.PoolSize ≤ 0 时的连接池大小兜底(对齐 config 注释建议 ≥ 工作池并发 + 2)。
+const defaultPoolSize = 10
+
+// Client Redis 客户端句柄(薄封装 go-redis 的 *gredis.Client,便于未来换实现/埋观测点)。
+//
+// 嵌入 *gredis.Client——所有命令方法(Set/Get/Del/Exists/Ping/...)+ Close 直接可用,
+// 业务 store(refresh_store.go / revocation_store.go)只依赖这个嵌入类型,
+// 方法经嵌入提升直接调用(s.Client.Set(...) 等),无需逐命令转发。
+//
+// 说明:本类型额外提供 Close()(shadow 嵌入类型的 Close,加 nil 接收者保护,
+// 统一"进程退出关闭"入口);调用方一律 `defer func() { _ = rc.Close() }()`。
+type Client struct {
+	*gredis.Client
+}
+
+// NewClient 建立 Redis 连接池并 Ping 探活(启动时装配一次,进程共享;探活失败应 fail-fast 阻止启动)。
+//
+// 入参:
+//   - cfg config.RedisCfg:Redis 配置(Addr 必填;DB 逻辑库编号 0-15;PoolSize 连接池大小,≤0 用默认)。
+//
+// 出参:
+//   - *Client:就绪的客户端。
+//   - error:以下场景非 nil:
+//     - cfg.Addr 为空(配置缺失,调用方应在 config.Load 层已拦截,此处双保险);
+//     - Ping 超时/连接被拒(Redis 不可用)→ 调用方按"启动失败"处理(token 链路强依赖,
+//       不允许"先启动后降级"——refresh/吊销存储缺失 = 登录态安全机制失效,安全红线)。
+//
+// 异常场景:
+//   - 双实例(ADR-015)共享同一 Redis:连接池各实例独立,键空间按前缀天然隔离(revocationKeyPrefix),
+//     无需 per-instance 命名空间;
+//   - Redis 后续运行期故障:本包不重试/不重连(go-redis 内部自带连接池自愈),
+//     业务 store 的错误语义(fail-closed)由 store 自身保证(见 revocation_store.go 注释)。
+func NewClient(cfg config.RedisCfg) (*Client, error) {
+	if cfg.Addr == "" {
+		return nil, fmt.Errorf("redis: 配置 Addr 为空(config.Redis.Addr 必填)")
+	}
+	pool := cfg.PoolSize
+	if pool <= 0 {
+		pool = defaultPoolSize
+	}
+	cli := gredis.NewClient(&gredis.Options{
+		Addr:     cfg.Addr,
+		DB:       cfg.DB,
+		PoolSize: pool,
+	})
+	ctx, cancel := context.WithTimeout(context.Background(), pingTimeout)
+	defer cancel()
+	if err := cli.Ping(ctx).Err(); err != nil {
+		// Ping 失败:关闭已建的连接池(避免泄漏),上抛
+		_ = cli.Close()
+		return nil, fmt.Errorf("redis: 探活失败(%s): %w", cfg.Addr, err)
+	}
+	return &Client{Client: cli}, nil
+}
+
+// Close 释放连接池(进程退出时调用;幂等,重复调用不报错)。
+//
+// 说明:shadow 嵌入 *gredis.Client 的 Close() error——调用方只看到 Client.Close 一个入口;
+// 内部委托给底层 go-redis 连接池关闭。nil 接收者 / 未初始化客户端时返回 nil(便于 defer 场景)。
+func (c *Client) Close() error {
+	if c == nil || c.Client == nil {
+		return nil
+	}
+	return c.Client.Close()
+}

+ 90 - 0
internal/infra/redis/refresh_store.go

@@ -0,0 +1,90 @@
+// refresh 白名单存储(满足 token 包定义的 token.RefreshStore 接口)。
+//
+// 键空间:refresh:{jti} → payload(token.RefreshPayload 的 JSON 序列化)。
+// TTL = refresh 有效期(由 token.Issuer 按 config.RefreshTokenTTLDay 设定,写入时透传)。
+//
+// 接口契约(token 包,本实现须严格遵守):
+//   - SetRefresh:写白名单(登录/rotate 签发 refresh 后调用);Redis 写失败必须上抛 error
+//     ——token.Issuer.Issue 会因此整体失败,不放行"只有 access 没有 refresh 白名单"的半套凭证;
+//   - GetRefresh:读白名单(rotate 前调用);键不存在返回 (nil, nil)(不区分"未签发"与"已轮换/过期",
+//     由 token.Rotate 统一按 ErrRefreshInvalid 收敛——防枚举"某 jti 是否存在");
+//     Redis 故障返回 (nil, error),token.Rotate 同样按 ErrRefreshInvalid 处理(故障时前端可重试登录);
+//   - DeleteRefresh:删白名单(rotate 删旧串 / 登出删当前串);幂等——键不存在不报错(DEL 天然幂等)。
+//
+// 键前缀 "refresh:" 硬编码在本文件(不暴露配置项):
+//   - 与吊销黑名单前缀(config.Auth.RevocationKeyPrefix,可配置)刻意分开——
+//     两键空间语义不同(白名单=正向凭证 / 黑名单=负向撤销),合并成一个前缀易在运维清键时误伤。
+package redis
+
+import (
+	"context"
+	"fmt"
+	"time"
+
+	gredis "github.com/redis/go-redis/v9"
+
+	"yuansiwei/unified-backend/internal/infra/errors"
+)
+
+// refreshKeyPrefix refresh 白名单键前缀(见包注释:与吊销前缀分离是刻意的)。
+const refreshKeyPrefix = "refresh:"
+
+// refreshKey 拼 refresh 白名单键。jti 为 token.Issuer 内部生成的 16 字节 hex(见 token 包 newJTI),
+// 已保证不含 Redis 键特殊字符(hex 字符集),无需额外转义。
+func refreshKey(jti string) string {
+	return refreshKeyPrefix + jti
+}
+
+// RefreshStore refresh 白名单存储(*Client 直接实现,装配时把 *Client 当 token.RefreshStore 传入)。
+//
+// 入参:均见 token.RefreshStore 接口注释。
+// 出参:error 语义见上方包注释"接口契约"。
+// 说明:本类型不新增字段——键前缀已硬编码,TTL 由调用方(Issuer)透传,无需存配置。
+type RefreshStore struct {
+	*Client
+}
+
+// NewRefreshStore 构造 refresh 白名单存储(装配入口)。
+func NewRefreshStore(rc *Client) *RefreshStore {
+	return &RefreshStore{Client: rc}
+}
+
+// SetRefresh 写入 refresh 白名单。
+//
+// 入参:ctx / jti / payload / ttl(见 token.RefreshStore 接口)。
+// 出参:error——Redis SET 失败(连接故障/超时)时返回包装后的 errors.ErrDBError;
+// 调用方(token.Issuer.Issue)必须按"整体签发失败"处理。
+func (s *RefreshStore) SetRefresh(ctx context.Context, jti string, payload []byte, ttl time.Duration) error {
+	if err := s.Client.Set(ctx, refreshKey(jti), payload, ttl).Err(); err != nil {
+		return errors.WithCause(errors.ErrDBError, fmt.Errorf("redis: 写 refresh 白名单 jti=%s 失败: %w", jti, err))
+	}
+	return nil
+}
+
+// GetRefresh 读取 refresh 白名单。
+//
+// 入参:ctx / jti(见接口)。
+// 出参:
+//   - payload:键存在时的 JSON 载荷;键不存在返回 nil(与错误区分——gredis.Nil 不是 error);
+//   - error:Redis 故障时非 nil(token.Rotate 按 ErrRefreshInvalid 收敛,前端可重试登录)。
+func (s *RefreshStore) GetRefresh(ctx context.Context, jti string) ([]byte, error) {
+	val, err := s.Client.Get(ctx, refreshKey(jti)).Bytes()
+	if err != nil {
+		if err == gredis.Nil {
+			return nil, nil // 键不存在(未签发/已轮换/已过期/已登出)——不报错
+		}
+		return nil, errors.WithCause(errors.ErrDBError, fmt.Errorf("redis: 读 refresh 白名单 jti=%s 失败: %w", jti, err))
+	}
+	return val, nil
+}
+
+// DeleteRefresh 删除 refresh 白名单(幂等)。
+//
+// 入参:ctx / jti(见接口)。
+// 出参:error——Redis DEL 失败时返回;键不存在时 go-redis 返回 (1|0, nil),不报错(幂等)。
+func (s *RefreshStore) DeleteRefresh(ctx context.Context, jti string) error {
+	if err := s.Client.Del(ctx, refreshKey(jti)).Err(); err != nil {
+		return errors.WithCause(errors.ErrDBError, fmt.Errorf("redis: 删 refresh 白名单 jti=%s 失败: %w", jti, err))
+	}
+	return nil
+}

+ 104 - 0
internal/infra/redis/revocation_store.go

@@ -0,0 +1,104 @@
+// 吊销黑名单存储(满足 token 包定义的 token.RevocationStore 接口)。
+//
+// 键空间:{revocationKeyPrefix}:{jti} → 标记存在(无 payload,存在即代表"该 jti 的 access 已被吊销")。
+// TTL = 该 access token 剩余有效期(由调用方计算后透传;TTL ≤ 0 时直接跳过写入——
+//   token 本已过期,无需进黑名单,省一次 Redis 写 + 一个键的内存占用)。
+//
+// 接口契约(token 包,本实现须严格遵守):
+//   - AddRevoked:写黑名单(改密/禁用/登出后调用);Redis 写失败必须上抛 error
+//     ——调用方(改密场景)必须阻断改密提交(凭证已变而旧 token 未吊销 = 安全漏洞);
+//   - IsRevoked:查黑名单(token.Verify 入口调用);
+//     - 键存在 → (true, nil);键不存在 → (false, nil);
+//     - Redis 故障 → (false, error)——**fail-closed**:调用方(token.Verify)必须拒绝请求,
+//       不可把"查不到"当作"未吊销"放行(吊销失效 = 改密后旧 token 仍可用,安全红线)。
+//
+// 前缀来源:config.Auth.RevocationKeyPrefix(可配置,默认建议 "revoked");
+// 不硬编码(与 refresh 白名单前缀刻意分离的对称设计:白名单前缀硬编码 "refresh:",
+// 黑名单前缀走配置——黑名单可能按环境/实例差异化命名,白名单必须全局一致)。
+package redis
+
+import (
+	"context"
+	"fmt"
+	"time"
+
+	"yuansiwei/unified-backend/internal/infra/errors"
+)
+
+// defaultRevocationKeyPrefix cfg.RevocationKeyPrefix 为空时的兜底前缀("revoked",
+// 对齐 token 包注释的键命名习惯)。装配方(main)应显式传配置值,此处仅为防御。
+const defaultRevocationKeyPrefix = "revoked"
+
+// RevocationStore 吊销黑名单存储(*Client 直接实现,装配时把 *Client 当 token.RevocationStore 传入)。
+//
+// 字段:
+//   - Client:Redis 客户端句柄(必填)。
+//   - prefix:键前缀(config.Auth.RevocationKeyPrefix,空时用 defaultRevocationKeyPrefix 兜底)。
+type RevocationStore struct {
+	*Client
+	prefix string
+}
+
+// NewRevocationStore 构造吊销黑名单存储(装配入口)。
+//
+// 入参:
+//   - rc *Client:Redis 客户端句柄(必填)。
+//   - prefix string:键前缀(config.Auth.RevocationKeyPrefix);空/空白时用默认 "revoked"。
+func NewRevocationStore(rc *Client, prefix string) *RevocationStore {
+	if prefix == "" {
+		prefix = defaultRevocationKeyPrefix
+	}
+	return &RevocationStore{Client: rc, prefix: prefix}
+}
+
+// revocationKey 拼吊销黑名单键。prefix + ":" + jti。
+// jti 为 16 字节 hex(见 token 包 newJTI),prefix 为运维配置(约定不含 ":"),无需额外转义。
+func (s *RevocationStore) revocationKey(jti string) string {
+	return s.prefix + ":" + jti
+}
+
+// AddRevoked 写入黑名单。
+//
+// 入参:ctx / jti / ttl(见 token.RevocationStore 接口)。
+// 出参:
+//   - error:Redis SET 失败(连接故障/超时)时返回 errors.ErrDBError 包装错误;
+//     ttl ≤ 0 时直接返回 nil(token 本已过期,无需进黑名单——省一次写 + 省内存)。
+//
+// 异常场景:
+//   - ttl 极大(理论上 access 剩余有效期 ≤ 30min,不可能极大):不设上限钳制,
+//     信任调用方(Issuer 按 accessTTL 计算剩余,不会传超大值)。
+func (s *RevocationStore) AddRevoked(ctx context.Context, jti string, ttl time.Duration) error {
+	if ttl <= 0 {
+		return nil // token 本已过期,不进黑名单(省一次写 + 省内存)
+	}
+	if err := s.Client.Set(ctx, s.revocationKey(jti), "1", ttl).Err(); err != nil {
+		return errors.WithCause(errors.ErrDBError, fmt.Errorf("redis: 写吊销黑名单 jti=%s 失败: %w", jti, err))
+	}
+	return nil
+}
+
+// IsRevoked 查询黑名单(fail-closed 语义)。
+//
+// 入参:ctx / jti(见接口)。
+// 出参:
+//   - bool:true 表示该 jti 已被吊销(调用方必须拒绝请求);false 表示未吊销。
+//   - error:
+//     - nil:Redis 查询成功(无论命中与否);
+//     - 非 nil:Redis 故障(连接失败/超时)——**调用方(token.Verify)必须 fail-closed**,
+//       把本次请求按"未通过验签"处理(响应 1002 登录态失效 + 告警),**不可**把 error 当"未吊销"放行。
+//
+// 实现说明:用 EXISTS 而非 GET——黑名单键的 payload 只是标记("1"),存在性本身就是语义,
+// EXISTS 比 GET 少一次 payload 读取,且不存在时返回 0 而非 nil(无需判 gredis.Nil)。
+func (s *RevocationStore) IsRevoked(ctx context.Context, jti string) (bool, error) {
+	n, err := s.Client.Exists(ctx, s.revocationKey(jti)).Result()
+	if err != nil {
+		// fail-closed:查询失败必须上抛 error,由调用方决定拒绝(不可静默当"未吊销")
+		return false, errors.WithCause(errors.ErrDBError, fmt.Errorf("redis: 查吊销黑名单 jti=%s 失败: %w", jti, err))
+	}
+	return n > 0, nil
+}
+
+// 编译期接口断言说明:*RevocationStore 须满足 token 包定义的 token.RevocationStore 接口
+// (AddRevoked + IsRevoked 两方法,签名见上方实现)。本包不 import identity/token(依赖方向铁律:
+// identity → infra,infra 零业务依赖),故跨包接口满足在 T4-6 main 装配处通过"把 *RevocationStore
+// 当 token.RevocationStore 传参"隐式完成——若后续误删/改签名,main 编译期即暴露,无需本处重复断言。

+ 236 - 0
internal/infra/redis/store_test.go

@@ -0,0 +1,236 @@
+// infra/redis store 单测(T4-1 验收):用 miniredis(纯 Go 嵌入式 Redis 替身,test-only)
+// 覆盖 RefreshStore / RevocationStore 的关键路径——往返、键不存在、TTL 过期、幂等删除、
+// 以及 fail-closed 故障语义(Redis 故障时 IsRevoked 必须返回 error 而非静默放行)。
+//
+// 为什么用 miniredis 而非连真实 Redis:
+//   - CI/无 Redis 环境也能跑(对齐 infra/db 测试"未设 DSN 则 skip"的纪律,但 store 逻辑更宜用
+//     嵌入式替身常跑,不留集成盲区);
+//   - TTL/FastForward 可精确控制时钟,真实 Redis 要 sleep 等待过期,慢且不稳;
+//   - fail-closed 故障路径(关 miniredis 模拟连接故障)在真实 Redis 上难以稳定复现。
+package redis
+
+import (
+	"context"
+	"testing"
+	"time"
+
+	miniredis "github.com/alicebob/miniredis/v2"
+
+	"yuansiwei/unified-backend/internal/infra/config"
+)
+
+// newMiniredis 起一个嵌入式 Redis(miniredis),测试结束自动 Close。
+// v2.34.0 的 Run() 返回 (*Miniredis, error),不接 testing.T,故此处统一封装判错 + cleanup。
+func newMiniredis(t *testing.T) *miniredis.Miniredis {
+	t.Helper()
+	mr, err := miniredis.Run()
+	if err != nil {
+		t.Fatalf("miniredis.Run: %v", err)
+	}
+	t.Cleanup(mr.Close)
+	return mr
+}
+
+// newTestClient 起一个 miniredis 并构造 *Client(探活走 miniredis,毫秒级通过)。
+func newTestClient(t *testing.T, mr *miniredis.Miniredis) *Client {
+	t.Helper()
+	rc, err := NewClient(config.RedisCfg{Addr: mr.Addr()})
+	if err != nil {
+		t.Fatalf("NewClient: %v", err)
+	}
+	t.Cleanup(func() { _ = rc.Close() })
+	return rc
+}
+
+// ---- NewClient ----
+
+// TestNewClient_PingOK 探活成功路径:miniredis 在监听,NewClient 应返回可用客户端。
+func TestNewClient_PingOK(t *testing.T) {
+	rc := newTestClient(t, newMiniredis(t))
+	if rc == nil || rc.Client == nil {
+		t.Fatal("NewClient 应返回非 nil 客户端")
+	}
+}
+
+// TestNewClient_EmptyAddr Addr 为空必须 fail-fast(配置缺失,不允许带病启动)。
+func TestNewClient_EmptyAddr(t *testing.T) {
+	if _, err := NewClient(config.RedisCfg{Addr: ""}); err == nil {
+		t.Fatal("Addr 为空时 NewClient 必须返回 error(fail-fast)")
+	}
+}
+
+// TestNewClient_Unreachable 连接被拒必须 fail-fast(Redis 不可用 = token 链路强依赖失效)。
+func TestNewClient_Unreachable(t *testing.T) {
+	// 指向一个本机未监听的端口(127.0.0.1:1 几乎必然被拒)
+	if _, err := NewClient(config.RedisCfg{Addr: "127.0.0.1:1"}); err == nil {
+		t.Fatal("连接被拒时 NewClient 必须返回 error(fail-fast)")
+	}
+}
+
+// ---- RefreshStore ----
+
+// TestRefreshStore_RoundTrip 写-读往返:SetRefresh 后 GetRefresh 应取回原 payload。
+func TestRefreshStore_RoundTrip(t *testing.T) {
+	mr := newMiniredis(t)
+	rc := newTestClient(t, mr)
+	rs := NewRefreshStore(rc)
+	ctx := context.Background()
+
+	payload := []byte(`{"sub":"ABC","uid":1,"roles":["student"],"institution_id":11,"campus_id":3}`)
+	if err := rs.SetRefresh(ctx, "jti-rt", payload, time.Hour); err != nil {
+		t.Fatalf("SetRefresh: %v", err)
+	}
+	got, err := rs.GetRefresh(ctx, "jti-rt")
+	if err != nil {
+		t.Fatalf("GetRefresh: %v", err)
+	}
+	if string(got) != string(payload) {
+		t.Fatalf("往返 payload 不符:期望 %q,实际 %q", payload, got)
+	}
+}
+
+// TestRefreshStore_GetMiss 键不存在返回 (nil, nil)——不区分"未签发/已轮换/过期"(防枚举,token 包契约)。
+func TestRefreshStore_GetMiss(t *testing.T) {
+	mr := newMiniredis(t)
+	rc := newTestClient(t, mr)
+	rs := NewRefreshStore(rc)
+	ctx := context.Background()
+
+	v, err := rs.GetRefresh(ctx, "jti-not-exist")
+	if err != nil {
+		t.Fatalf("键不存在时 GetRefresh 不应报错,实际: %v", err)
+	}
+	if v != nil {
+		t.Fatalf("键不存在时 GetRefresh 应返回 nil payload,实际: %q", v)
+	}
+}
+
+// TestRefreshStore_DeleteIdempotent DeleteRefresh 幂等:删不存在的键不报错(DEL 天然幂等)。
+func TestRefreshStore_DeleteIdempotent(t *testing.T) {
+	mr := newMiniredis(t)
+	rc := newTestClient(t, mr)
+	rs := NewRefreshStore(rc)
+	ctx := context.Background()
+
+	if err := rs.DeleteRefresh(ctx, "jti-never-existed"); err != nil {
+		t.Fatalf("删不存在的键不应报错(幂等),实际: %v", err)
+	}
+}
+
+// TestRefreshStore_TTL TTL 生效:写入后 FastForward 超过 TTL,GetRefresh 应返回 nil。
+func TestRefreshStore_TTL(t *testing.T) {
+	mr := newMiniredis(t)
+	rc := newTestClient(t, mr)
+	rs := NewRefreshStore(rc)
+	ctx := context.Background()
+
+	if err := rs.SetRefresh(ctx, "jti-ttl", []byte("payload"), 5*time.Second); err != nil {
+		t.Fatalf("SetRefresh: %v", err)
+	}
+	// 未过期:可读
+	if v, _ := rs.GetRefresh(ctx, "jti-ttl"); v == nil {
+		t.Fatal("TTL 内 GetRefresh 应可读")
+	}
+	// 快进超过 TTL:键过期,读回 nil
+	mr.FastForward(6 * time.Second)
+	v2, err := rs.GetRefresh(ctx, "jti-ttl")
+	if err != nil {
+		t.Fatalf("GetRefresh: %v", err)
+	}
+	if v2 != nil {
+		t.Fatalf("TTL 过期后 GetRefresh 应返回 nil,实际: %q", v2)
+	}
+}
+
+// ---- RevocationStore ----
+
+// TestRevocationStore_Hit 写后查命中:AddRevoked 后 IsRevoked 应返回 true。
+func TestRevocationStore_Hit(t *testing.T) {
+	mr := newMiniredis(t)
+	rc := newTestClient(t, mr)
+	rs := NewRevocationStore(rc, "revoked")
+	ctx := context.Background()
+
+	if err := rs.AddRevoked(ctx, "jti-h", time.Minute); err != nil {
+		t.Fatalf("AddRevoked: %v", err)
+	}
+	revoked, err := rs.IsRevoked(ctx, "jti-h")
+	if err != nil {
+		t.Fatalf("IsRevoked: %v", err)
+	}
+	if !revoked {
+		t.Fatal("AddRevoked 后 IsRevoked 应返回 true(命中黑名单)")
+	}
+}
+
+// TestRevocationStore_Miss 未写入的 jti:IsRevoked 返回 (false, nil)。
+func TestRevocationStore_Miss(t *testing.T) {
+	mr := newMiniredis(t)
+	rc := newTestClient(t, mr)
+	rs := NewRevocationStore(rc, "revoked")
+	ctx := context.Background()
+
+	revoked, err := rs.IsRevoked(ctx, "jti-never")
+	if err != nil {
+		t.Fatalf("IsRevoked: %v", err)
+	}
+	if revoked {
+		t.Fatal("未写入的 jti IsRevoked 应返回 false(未吊销)")
+	}
+}
+
+// TestRevocationStore_TTLZero ttl ≤ 0 不写键(token 本已过期,无需进黑名单)。
+func TestRevocationStore_TTLZero(t *testing.T) {
+	mr := newMiniredis(t)
+	rc := newTestClient(t, mr)
+	rs := NewRevocationStore(rc, "revoked")
+	ctx := context.Background()
+
+	if err := rs.AddRevoked(ctx, "jti-zero", 0); err != nil {
+		t.Fatalf("AddRevoked(ttl=0): %v", err)
+	}
+	// ttl≤0 不写键 → IsRevoked 应 false
+	revoked, err := rs.IsRevoked(ctx, "jti-zero")
+	if err != nil {
+		t.Fatalf("IsRevoked: %v", err)
+	}
+	if revoked {
+		t.Fatal("ttl=0 时 AddRevoked 不写键,IsRevoked 应 false")
+	}
+}
+
+// TestRevocationStore_FailClosed 关键安全语义:Redis 故障时 IsRevoked 必须返回 error(fail-closed),
+// 绝不可把"查不到"当作"未吊销"静默放行——吊销失效 = 改密后旧 token 仍可用,安全红线。
+func TestRevocationStore_FailClosed(t *testing.T) {
+	mr := newMiniredis(t)
+	rc := newTestClient(t, mr)
+	rs := NewRevocationStore(rc, "revoked")
+	ctx := context.Background()
+
+	// 正常态:IsRevoked 返回 (false, nil)
+	if _, err := rs.IsRevoked(ctx, "jti-fc"); err != nil {
+		t.Fatalf("正常态 IsRevoked 不应报错: %v", err)
+	}
+	// 关停 miniredis 模拟连接故障 → IsRevoked 必须 fail-closed 返回 error
+	mr.Close()
+	if _, err := rs.IsRevoked(ctx, "jti-fc"); err == nil {
+		t.Fatal("Redis 故障后 IsRevoked 必须返回 error(fail-closed),实际返回 nil(= 静默放行,安全漏洞)")
+	}
+}
+
+// TestRevocationStore_DefaultPrefix 前缀为空时用默认 "revoked"(防御,装配方应显式传配置值)。
+func TestRevocationStore_DefaultPrefix(t *testing.T) {
+	mr := newMiniredis(t)
+	rc := newTestClient(t, mr)
+	rs := NewRevocationStore(rc, "") // 空前缀 → 默认 "revoked"
+	ctx := context.Background()
+
+	if err := rs.AddRevoked(ctx, "jti-dp", time.Minute); err != nil {
+		t.Fatalf("AddRevoked: %v", err)
+	}
+	// 直接用底层 client 确认键落在 "revoked:jti-dp"
+	exists := mr.Exists("revoked:jti-dp")
+	if !exists {
+		t.Fatal("空前缀应回落默认前缀 revoked,键 revoked:jti-dp 应存在")
+	}
+}