Posted in

为什么Gin-JWT中间件的RefreshHandler总在并发下返回500?Go原生http.Handler原子状态修复详解

第一章:Gin-JWT RefreshHandler并发500问题的现象与定位

在高并发场景下,使用 Gin 框架配合 github.com/appleboy/gin-jwt/v2 实现 JWT 刷新机制时,RefreshHandler 接口频繁返回 HTTP 500 错误,错误日志中常出现 token is expired 或 invalid token,但客户端传入的 refresh token 实际未过期。该问题具有明显的时间敏感性与并发相关性——单请求正常,QPS 超过 30 后错误率陡升至 40% 以上。

现象复现步骤

  1. 启动标准 Gin-JWT 示例服务(启用 RefreshToken 和 RefreshHandler);
  2. 使用 wrk -t4 -c100 -d30s http://localhost:8080/refresh 模拟并发刷新请求;
  3. 观察响应状态码分布及日志,确认 500 Internal Server Error 集中出现在 RefreshHandler 路由。

根本原因分析

gin-jwt/v2 的 RefreshHandler 默认复用 Middleware.LoginHandler 中的 token 解析逻辑,但未对 refresh token 的验证上下文做隔离。关键缺陷在于:

  • jwt.ParseToken() 在并发调用时共享底层 jwt.Parser 实例(含默认 ValidateExp 验证器);
  • 当系统时间发生微小回跳(如 NTP 同步或虚拟机时钟漂移),多个 goroutine 可能同时触发 exp 校验失败并 panic;
  • gin-jwt 的错误处理未捕获 jwt.ValidationError 子类,直接向上抛出导致 500。

验证与修复代码片段

// 替换默认 Parser,禁用 exp 自动校验,交由业务逻辑显式控制
auth, _ := jwt.New(&jwt.GinJWTMiddleware{
    // ... 其他配置
    TokenLookup: "header: Authorization, query: token, cookie: jwt",
    // 关键修复:自定义 Parser,关闭自动 exp 检查
    Parser: &jwt.Parser{Validations: &jwt.Validation{VerifyExp: false}},
})

并发错误特征对比表

特征 正常单请求 并发刷新(>50 QPS)
响应状态码 200 500 占比 ≥35%
日志关键错误文本 无 token is expired / panic: invalid memory address
token 解析耗时均值 ~0.8ms 波动剧烈(0.3–12ms)

第二章:JWT刷新机制与Go HTTP状态管理的底层原理

2.1 JWT刷新流程的RFC标准与Gin-JWT实现差异分析

RFC 7519 明确规定:JWT 本身不可刷新,刷新需依赖外部机制(如 refresh_token 交换新 access_token),且要求 refresh token 具备短期有效、单次使用、绑定客户端/设备等安全约束。

RFC 标准核心要求

  • Refresh token 必须独立于 access token 存储与签发
  • 服务端需校验 refresh token 的合法性、时效性、使用次数及绑定关系
  • 成功后须立即作废旧 refresh token(“use-once”)

Gin-JWT 的简化实践

// gin-jwt 默认 refresh handler(精简示意)
func (mw *GinJWTMiddleware) RefreshHandler(c *gin.Context) {
    token, _ := mw.JWTMiddleware.ParseToken(c.Request.Header.Get("Authorization"))
    newToken := mw.JWTMiddleware.Sign(token.Claims.(jwt.MapClaims)) // 直接续签,未验证 refresh token 独立性
    c.JSON(200, gin.H{"token": newToken})
}

⚠️ 此实现跳过 refresh token 校验、未作废原 token、未绑定设备指纹——违背 RFC 的状态管理原则。

关键差异对比

维度 RFC 7519 合规方案 Gin-JWT 默认实现
Token 独立性 access_token 与 refresh_token 分离签发 复用同一密钥与 payload 续签
作废机制 服务端存储并即时作废旧 refresh token 无存储、无作废逻辑
绑定验证 强制校验 client_id、jti、IP/UA 完全忽略
graph TD
    A[Client 请求 /refresh] --> B{RFC 合规流程}
    B --> C[校验 refresh_token 签名与时效]
    C --> D[查库确认未使用且绑定匹配]
    D --> E[生成新 access_token + 新 refresh_token]
    E --> F[作废旧 refresh_token]
    A --> G{Gin-JWT 默认流程}
    G --> H[仅解析原 access_token]
    H --> I[直接重签名生成新 token]
    I --> J[无校验、无作废、无新 refresh]

2.2 Go原生http.Handler的非原子性状态:Request/ResponseWriter生命周期剖析

Go 的 http.Handler 接口看似简洁,但 *http.Request 与 http.ResponseWriter 的状态管理并非原子性——二者生命周期独立,且可被并发协程意外干扰。

请求与响应对象的解耦本质

  • *http.Request 是只读快照(除 Context() 可派生外),但其 Body 是 io.ReadCloser,多次读取将失败;
  • http.ResponseWriter 是接口,底层 response 结构体维护 wroteHeader、written 等字段,写入响应后不可重置。

关键生命周期节点对比

阶段 Request 状态可变点 ResponseWriter 状态可变点
进入 Handler r.Context().Done() 可能已关闭 Header() 可修改,WriteHeader() 未调用
中间处理 r.Body 可读一次(需 r.Body.Close()) Write() 触发隐式 WriteHeader(http.StatusOK)
返回前 r.Context() 可能超时/取消 一旦 WriteHeader() 或 Write() 调用,wroteHeader = true,后续 Header 修改无效
func atomicSafeHandler(w http.ResponseWriter, r *http.Request) {
    // ❌ 危险:并发读 Body + defer Close 可能导致 io.EOF 或 panic
    body, _ := io.ReadAll(r.Body)
    defer r.Body.Close() // 错误:Body 已被 ReadAll 消耗,Close 无意义且掩盖问题

    // ✅ 正确:显式检查并确保仅读一次
    if r.Body == http.NoBody {
        http.Error(w, "empty body", http.StatusBadRequest)
        return
    }
}

逻辑分析:io.ReadAll(r.Body) 消耗流后,r.Body.Close() 实际调用的是 ioutil.NopCloser(nil).Close()(空操作),但若中间件提前读过 Body(如日志中间件),此处将读到空字节且无错误提示;http.NoBody 是哨兵值,用于避免 nil-dereference。参数 r.Body 是 io.ReadCloser,其契约要求使用者负责关闭——但仅当它非 NoBody 且未被其他组件关闭时才应调用 Close()。

graph TD
    A[Handler 调用] --> B{r.Body 是否已读?}
    B -->|是| C[ReadAll 返回 []byte{}]
    B -->|否| D[ReadAll 读取原始字节]
    D --> E[r.Body.Close() 释放连接资源]
    C --> F[潜在逻辑错误:误判有效载荷]

2.3 并发场景下RefreshHandler中token解析与写入的竞争条件复现实验

数据同步机制

RefreshHandler 在高并发下对同一用户会话频繁触发 parseToken() 与 writeToken(),二者共享内存中的 tokenCache(ConcurrentHashMap<String, Token>),但未对单个 key 的读-改-写操作加锁。

复现关键路径

  • 线程 A 解析旧 token → 生成新 token 对象
  • 线程 B 同时解析相同旧 token → 生成另一份新 token 对象
  • 两者先后调用 cache.put(userId, newToken),后者覆盖前者

竞争验证代码

// 模拟双线程并发刷新同一 userId
ExecutorService exec = Executors.newFixedThreadPool(2);
for (int i = 0; i < 2; i++) {
    exec.submit(() -> refreshHandler.refresh("user_123")); // 无锁刷新
}
exec.shutdown();

逻辑分析:refresh() 内部先 parseToken()(无状态纯函数),再 generateNewToken()(依赖当前时间戳),最后 cache.put()。因缺少 computeIfAbsent 或 synchronized(cacheKey),导致两个不同时间戳的 token 被竞态写入,最终缓存仅保留后者。

线程 parseToken() 结果 generateNewToken().exp 写入缓存时间
A exp=1710000000 1710000100 t₁
B exp=1710000000 1710000105 t₂ > t₁
graph TD
    A[Thread A] -->|parse→generate| A1[Token{exp:100}]
    B[Thread B] -->|parse→generate| B1[Token{exp:105}]
    A1 -->|cache.put| C[cache[user_123] = Token100]
    B1 -->|cache.put| C[cache[user_123] = Token105]

2.4 Gin上下文(c *gin.Context)在中间件链中的共享状态风险验证

数据同步机制

*gin.Context 是中间件链中唯一跨层传递的上下文对象,其内部 Keys 映射(map[string]interface{})和 Value 字段被所有中间件共享——无并发保护,非线程安全。

风险复现代码

func RaceMiddleware() gin.HandlerFunc {
    return func(c *gin.Context) {
        c.Set("user_id", rand.Intn(1000)) // 竞态写入
        time.Sleep(1 * time.Millisecond)
        id := c.GetInt("user_id")          // 可能读到其他请求写入的值
        c.Set("processed_id", id)
        c.Next()
    }
}

逻辑分析:多个并发请求共用同一 c 实例;c.Set() 直接修改 c.Keys 共享映射,无锁保护。参数 c 是栈上指针,但所指结构体在请求生命周期内被复用,导致中间件间隐式共享。

关键风险对比

场景 是否安全 原因
单请求串行中间件调用 ✅ 顺序执行,无竞态
高并发+异步 goroutine ❌ c 被多个 goroutine 同时读写
graph TD
    A[Request] --> B[Middleware A]
    B --> C[Middleware B]
    C --> D[Handler]
    B -.->|共享 c.Keys| C
    C -.->|共享 c.Keys| D

2.5 Go内存模型视角:sync.Pool误用与Context.Value竞态的实测诊断

数据同步机制

Go内存模型不保证跨goroutine对Context.Value的读写顺序可见性。以下代码在高并发下触发竞态:

func badHandler(ctx context.Context, wg *sync.WaitGroup) {
    ctx = context.WithValue(ctx, "key", rand.Intn(100))
    time.Sleep(1 * time.Nanosecond) // 触发调度器切换
    val := ctx.Value("key")          // 可能读到旧值或nil
    _ = val
    wg.Done()
}

context.WithValue返回新上下文,但底层*valueCtx字段未加内存屏障;time.Sleep诱发goroutine抢占,使读操作可能观测到未完成的写。

sync.Pool典型误用

  • 将含指针的结构体放入Pool后不清零,导致悬垂引用
  • 在HTTP handler中复用bytes.Buffer却未调用Reset()
场景 风险 修复方式
Pool对象含sync.Mutex 复用时锁状态残留 New工厂函数中初始化新实例
Context.Value存大对象 GC压力+内存泄漏 改用显式参数传递
graph TD
    A[goroutine A: WithValue] -->|无同步原语| B[内存重排序]
    C[goroutine B: Value] -->|可能读到stale cache| B
    B --> D[竞态检测器报错]

第三章:原子状态修复的核心技术路径

3.1 基于sync.RWMutex的令牌刷新临界区安全封装实践

数据同步机制

令牌刷新需保证:读多写少、写操作互斥、读操作并发安全。sync.RWMutex 恰好满足该场景——允许多个 goroutine 同时读,但写时阻塞所有读写。

安全封装结构

type TokenManager struct {
    mu   sync.RWMutex
    token string
    expiry time.Time
}

func (tm *TokenManager) Get() (string, bool) {
    tm.mu.RLock()
    defer tm.mu.RUnlock()
    return tm.token, time.Now().Before(tm.expiry)
}

逻辑分析:RLock() 非阻塞获取读锁,defer 确保及时释放;返回值含有效性判断,避免调用方二次校验。token 和 expiry 作为原子读取对,防止竞态导致过期 token 被误用。

刷新流程图

graph TD
    A[请求访问] --> B{是否过期?}
    B -->|否| C[直接返回 token]
    B -->|是| D[获取写锁]
    D --> E[调用 OAuth2 接口刷新]
    E --> F[更新 token & expiry]
    F --> G[释放写锁]
    G --> C

关键设计对比

方案 读性能 写吞吐 实现复杂度
sync.Mutex 低 中 低
sync.RWMutex 高 中 低
atomic.Value 极高 低 高

3.2 利用http.StripPrefix+自定义Handler实现无状态RefreshEndpoint重构

传统 /actuator/refresh 端点依赖 Spring Boot Actuator 的有状态上下文,难以横向扩展。我们通过剥离路径前缀与状态耦合,构建纯函数式刷新处理器。

核心重构策略

  • 使用 http.StripPrefix("/actuator", h) 剥离路径前缀,使 Handler 只关注业务逻辑
  • 自定义 RefreshHandler 实现 http.Handler 接口,不持有任何实例字段
  • 所有依赖(如 ConfigRefresher)通过构造函数注入,确保无状态性

关键代码实现

type RefreshHandler struct {
    refresher ConfigRefresher // 依赖注入,非全局单例
}

func (h *RefreshHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
    if r.Method != "POST" {
        http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
        return
    }
    err := h.refresher.Refresh() // 触发配置重载,无副作用
    if err != nil {
        http.Error(w, err.Error(), http.StatusInternalServerError)
        return
    }
    w.WriteHeader(http.StatusOK)
    w.Write([]byte(`{"status":"refreshed"}`))
}

逻辑分析:StripPrefix 将 /actuator/refresh 转为 /refresh 后交由 RefreshHandler 处理;refresher.Refresh() 是幂等操作,不修改自身状态,符合无状态约束;响应体为固定 JSON,避免序列化开销。

对比优势

维度 传统 Actuator Endpoint 本方案
状态保持 依赖 ApplicationContext 完全无状态
可伸缩性 需共享上下文 实例可无限水平扩展
依赖注入粒度 框架级自动装配 显式、可控、可测试

3.3 基于context.WithValue与cancelable context的请求级状态隔离方案

在高并发 HTTP 服务中,需为每个请求独立携带认证信息、追踪 ID 与超时控制,避免 goroutine 间状态污染。

核心组合模式

  • context.WithCancel 提供可终止生命周期
  • context.WithValue 注入不可变请求元数据

典型初始化代码

func handleRequest(w http.ResponseWriter, r *http.Request) {
    // 创建可取消上下文,绑定请求生命周期
    ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
    defer cancel() // 确保及时释放资源

    // 注入请求级状态:用户ID、traceID(仅限不可变小数据)
    ctx = context.WithValue(ctx, "userID", "u_abc123")
    ctx = context.WithValue(ctx, "traceID", r.Header.Get("X-Trace-ID"))

    process(ctx, w)
}

逻辑分析:r.Context() 继承自服务器,WithTimeout 封装后支持自动超时取消;WithValue 键应为自定义类型(如 type userIDKey struct{})以避免键冲突,此处为简化演示使用字符串键。值必须是线程安全且不可变的。

安全使用约束

  • ✅ 允许:传递只读元数据(如 auth token、request ID)
  • ❌ 禁止:传递结构体指针、数据库连接、函数等可变/重量级对象
场景 是否推荐 原因
用户身份标识 ✅ 不可变、轻量、全局唯一
Redis 连接实例 ❌ 非线程安全,生命周期难管理
日志字段 map[string]any ⚠️ 若只读且深拷贝,可谨慎使用

第四章:生产级刷新令牌服务的工程化落地

4.1 Redis分布式锁协同JWT刷新的幂等性保障实现

在高并发场景下,JWT自动续期易引发重复刷新、令牌覆盖等非幂等问题。核心解法是将Redis分布式锁与JWT刷新生命周期深度耦合。

关键设计原则

  • 锁粒度精确到 refresh:{userId}:{jti}(jti为JWT唯一标识)
  • 锁过期时间 > 刷新操作最大耗时(建议 3s)
  • 刷新成功后立即删除锁,避免误释放

加锁与刷新原子流程

String lockKey = "refresh:" + userId + ":" + jti;
Boolean isLocked = redisTemplate.opsForValue()
    .setIfAbsent(lockKey, "1", Duration.ofSeconds(3));
if (Boolean.TRUE.equals(isLocked)) {
    try {
        String newToken = jwtService.refresh(token); // 实际刷新逻辑
        redisTemplate.opsForValue().set("token:" + userId, newToken, Duration.ofHours(2));
        return ResponseEntity.ok(newToken);
    } finally {
        redisTemplate.delete(lockKey); // 必须finally释放
    }
} else {
    throw new ConflictException("Refresh in progress");
}

逻辑分析:setIfAbsent 原子写入确保仅首个请求获得锁;Duration.ofSeconds(3) 防死锁;finally 保障锁必释,避免雪崩。jti 绑定使同一旧令牌的多次刷新请求串行化。

状态流转示意

graph TD
    A[客户端发起刷新] --> B{Redis加锁成功?}
    B -->|是| C[执行JWT解析+签发]
    B -->|否| D[返回409冲突]
    C --> E[写入新Token至Redis]
    C --> F[删除锁]
    E --> G[响应新Token]
组件 作用 幂等性贡献
Redis锁key refresh:{uid}:{jti} 按旧令牌唯一标识隔离并发
JWT jti字段 标准声明,服务端生成 确保每次刷新可追溯可去重
锁TTL策略 严格大于业务最大延迟 防锁残留导致长期阻塞

4.2 Gin中间件链中RefreshHandler的零拷贝响应体构造技巧

Gin 中 RefreshHandler 在中间件链中需避免内存复制,尤其在高频 Token 刷新场景下。

零拷贝核心:复用 bytes.Buffer 与 io.Writer

func RefreshHandler(c *gin.Context) {
    // 复用预分配的 buffer(来自 sync.Pool)
    buf := bufPool.Get().(*bytes.Buffer)
    buf.Reset()
    defer bufPool.Put(buf)

    // 直接写入 JSON 到 buffer,不经过 string → []byte 转换
    json.NewEncoder(buf).Encode(map[string]string{
        "access_token": c.GetString("new_token"),
        "expires_in":   "3600",
    })

    // 零拷贝:用 buf.Bytes() 返回底层切片(非 copy)
    c.Data(http.StatusOK, "application/json", buf.Bytes())
}

buf.Bytes() 返回底层数组视图,无内存分配;c.Data() 内部直接调用 http.ResponseWriter.Write(),跳过 Gin 默认的 []byte 拷贝路径。

性能对比(10K QPS 下)

方式 分配次数/请求 平均延迟
c.JSON() 2 1.8ms
c.Data() + buf.Bytes() 0(pool 复用) 0.9ms

关键约束

  • buf.Bytes() 返回的切片生命周期仅限于本次 HTTP write;
  • 必须确保 buf 不被后续 goroutine 持有或修改。

4.3 结合OpenTelemetry的刷新链路追踪与500错误根因自动归类

当API刷新失败触发500响应时,传统日志难以定位是缓存层超时、下游服务熔断,还是序列化异常。OpenTelemetry通过注入trace_id贯穿请求全生命周期,并在HTTP拦截器中自动捕获状态码与异常类型。

数据同步机制

在Spring Boot应用中注入自定义Span处理器:

@Bean
public SpanProcessor spanProcessor() {
    return BatchSpanProcessor.builder(OtlpGrpcSpanExporter.builder()
            .setEndpoint("http://otel-collector:4317") // OpenTelemetry Collector gRPC端点
            .build())
        .setScheduleDelay(100, TimeUnit.MILLISECONDS) // 批量上报延迟
        .build();
}

该配置确保所有Span(含http.status_code=500)实时上报至Collector,为后续规则引擎提供结构化信号源。

根因分类规则表

异常特征 归类标签 触发条件
redis.timeout in span 缓存超时 span.name == "redis.GET"
feign.RetryableException 下游不可用 exception.type == "FeignException"

自动归因流程

graph TD
    A[HTTP 500响应] --> B{提取Span属性}
    B --> C[status.code==500 ∧ exception.type]
    C --> D[匹配预置规则表]
    D --> E[打标 root_cause: cache_timeout]

4.4 单元测试+ghz压测驱动的并发刷新稳定性验证框架搭建

核心设计思想

以单元测试保障逻辑正确性,ghz(gRPC benchmarking tool)模拟高并发刷新请求,双轨协同验证服务在状态更新场景下的数据一致性与响应稳定性。

自动化验证流程

# 启动带健康检查的本地gRPC服务(含刷新端点)
./refresh-service --mode=test --port=9090

# 并发100路、持续30秒压测 /Refresh 接口
ghz --insecure --proto=api/refresh.proto \
    --call=pb.RefreshService/Refresh \
    -d '{"key":"user_123"}' \
    -c 100 -z 30s \
    0.0.0.0:9090

此命令启用100个并发连接,每秒自动重试失败请求;-z 30s确保覆盖GC周期与缓存失效窗口,暴露竞态条件。

验证维度对照表

维度 单元测试覆盖 ghz压测补充验证
时序一致性 ✅ 模拟单线程状态跃迁 ⚠️ 多goroutine写冲突检测
资源泄漏 ❌ 无法观测堆增长 ✅ pprof集成监控内存/协程数
响应P99延迟 ❌ 无真实调度开销 ✅ 真实网络+调度抖动建模

数据同步机制

// 刷新前强制同步:避免缓存与DB短暂不一致
func (s *Service) Refresh(ctx context.Context, req *pb.RefreshRequest) (*pb.RefreshResponse, error) {
    s.mu.Lock() // 全局刷新锁(粒度可调)
    defer s.mu.Unlock()
    if err := s.cache.Invalidate(req.Key); err != nil {
        return nil, err
    }
    return &pb.RefreshResponse{Updated: true}, nil
}

s.mu.Lock()防止并发刷新引发中间态脏读;实际生产中可替换为基于Redis分布式锁或版本号乐观控制,此处保留轻量实现便于单元测试断言。

第五章:从Gin-JWT到通用认证中间件的设计升维

认证需求的演进痛点

某电商平台初期仅需校验用户登录态,直接集成 gin-jwt 实现 /api/v1/user/info 接口的 Token 验证。但随着业务扩展,出现多场景认证需求:后台管理需 RBAC 权限校验、API 网关需支持 API Key + JWT 混合鉴权、第三方回调接口需 HMAC 签名验证、内部服务间调用需 mTLS 双向证书校验。硬编码 gin-jwt 中间件导致每个新认证方式都需复制粘贴逻辑、重复解析 Authorization 头、重复构造 context.WithValue,维护成本激增。

抽象认证契约接口

我们定义统一认证契约,解耦认证逻辑与 HTTP 层:

type Authenticator interface {
    Authenticate(c *gin.Context) (identity.Identity, error)
}

type Identity interface {
    GetID() string
    GetRoles() []string
    GetClaims() map[string]interface{}
    IsAuthenticated() bool
}

该接口屏蔽底层实现差异,使中间件可插拔切换。

多策略认证中间件实现

核心中间件通过策略注册表动态分发请求:

策略标识 触发条件 实现类
jwt Authorization: Bearer <token> 且 X-Auth-Type: jwt JWTAuthenticator
api-key X-API-Key 头存在且非空 APIKeyAuthenticator
hmac X-Signature + X-Timestamp + X-Nonce 全部存在 HMACAuthenticator
func AuthMiddleware(registry *AuthRegistry) gin.HandlerFunc {
    return func(c *gin.Context) {
        authType := c.GetHeader("X-Auth-Type")
        if authType == "" {
            authType = "jwt" // 默认策略
        }
        auth, ok := registry.Get(authType)
        if !ok {
            c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "unsupported auth type"})
            return
        }
        identity, err := auth.Authenticate(c)
        if err != nil {
            c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": err.Error()})
            return
        }
        c.Set("identity", identity)
        c.Next()
    }
}

权限决策的声明式增强

在认证后注入权限检查,支持路径级细粒度控制:

// 注册带权限的路由
router.GET("/admin/logs", 
    AuthMiddleware(registry),
    RequireRole("admin"),
    RequirePermission("log:read"),
    handler.GetLogs)

RequirePermission 中间件基于 c.MustGet("identity").GetRoles() 查询预加载的权限映射表(Redis Hash),避免每次请求查 DB。

认证上下文的可观测性埋点

所有认证流程自动注入 OpenTelemetry Span:

graph LR
A[HTTP Request] --> B{AuthMiddleware}
B --> C[Parse Auth Header]
C --> D[Call Authenticator.Authenticate]
D --> E{Success?}
E -->|Yes| F[Inject Identity & Span Attributes]
E -->|No| G[Log Failure Reason & Status Code]
F --> H[Continue Chain]
G --> I[Abort With Error]

Span 标签包含 auth.strategy, auth.status, auth.duration_ms, identity.id,支撑 SLO 分析与异常归因。

生产环境灰度发布机制

通过 X-Auth-Strategy-Override 头临时切换策略,配合 Prometheus 指标监控各策略成功率与延迟,逐步将 jwt-v1 迁移至兼容 OIDC 的 jwt-v2 实现,零停机完成升级。

守护服务器稳定运行,自动化是喵的最爱。

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注