Spring Boot 客户端设计示例:自动刷新 Token 并重试接口调用(Springboot Starter 封装)

在实际项目中,我们经常需要调用外部系统的接口,这些接口通常通过 Access Token 做鉴权。Token 可能会过期或者被重置,如果不做处理,接口调用就会失败。本文用"IM 系统"作为示例,介绍一个 通用客户端设计方案,支持 Token 缓存、自动刷新以及失败重试。

⚠️ 这里的 IM 系统仅作为示例,方案可应用于其他系统。本文用的IM是box IM,如果相同可以直接使用

实现逻辑

1. 配置类

客户端需要的配置信息如下:

properties 复制代码
package cn.iocoder.yudao.framework.common.im;

import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;

/**
 * @Author: zr
 * @Date: 2025/12/02/10:41
 * @Description:
 */
@Component
@ConfigurationProperties(prefix = "im.auth")
@Data
public class ImAuthProperties {

    private String baseUrl;
    private String tenantId;
    private String username;
    private String password;
    private String clientId;
    private String grantType;
}

YAML 配置示例:

yaml 复制代码
im:
  auth:
    base-url: http://127.0.0.1:8889
    tenant-id: 000000
    username: admin
    password: xxxxxxxxxxxxx
    client-id: xxxxxxxxxxxx
    grant-type: password

2. Token 管理器

IMTokenManager 负责管理 Token,包括:

  • Redis 缓存 Token
  • 分布式锁防止并发刷新
  • Token 失效自动刷新
  • 接口调用失败自动重试
java 复制代码
import cn.hutool.core.util.BooleanUtil;
import cn.hutool.core.util.StrUtil;
import cn.iocoder.yudao.framework.common.im.vo.ImResult;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.core.ParameterizedTypeReference;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.http.*;
import org.springframework.stereotype.Component;
import org.springframework.web.client.HttpClientErrorException;
import org.springframework.web.client.RestTemplate;

import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.TimeUnit;

/**
 * @Author: zr
 * @Date: 2025/12/02/10:42
 * @Description:
 */
@Component
@RequiredArgsConstructor
@Slf4j
public class IMTokenManager {

    private final ImAuthProperties properties;
    private final RedisTemplate<String, String> redisTemplate;
    private final RestTemplate restTemplate = new RestTemplate();

    private static final String TOKEN_KEY = "IM:access_token";
    private static final String LOCK_KEY = "IM:token_lock";
    private static final String LOGIN_PATH = "/auth/login";

    /** 获取 Token(自动刷新) */
    public String getIMToken() {
        String token = redisTemplate.opsForValue().get(TOKEN_KEY);
        if (StrUtil.isNotBlank(token)) {
            return token;
        }
        return refreshTokenWithLock();
    }

    /** 强制刷新 Token(不管 Redis 中有没有) */
    public String refreshTokenForce() {
        return refreshTokenWithLock();
    }

    /** 刷新 token 并加锁防止并发刷新 */
    private String refreshTokenWithLock() {
        Boolean locked = redisTemplate.opsForValue()
        .setIfAbsent(LOCK_KEY, "1", 10, TimeUnit.SECONDS);
        if (BooleanUtil.isFalse(locked)) {
            // 等待其他线程刷新
            try { Thread.sleep(200); } catch (InterruptedException ignored) {}
            return redisTemplate.opsForValue().get(TOKEN_KEY);
        }

        try {
            return refreshToken();
        } finally {
            redisTemplate.delete(LOCK_KEY);
        }
    }

    /** 真正刷新 token 方法 */
    private String refreshToken() {
        String loginUrl = properties.getBaseUrl() + LOGIN_PATH;

        Map<String, Object> req = new HashMap<>();
        req.put("tenantId", properties.getTenantId());
        req.put("username", properties.getUsername());
        req.put("password", properties.getPassword());
        req.put("rememberMe", properties.getRememberMe());
        req.put("clientId", properties.getClientId());
        req.put("grantType", properties.getGrantType());

        HttpHeaders headers = new HttpHeaders();
        headers.setContentType(MediaType.APPLICATION_JSON);
        HttpEntity<Map<String, Object>> entity = new HttpEntity<>(req, headers);

        ResponseEntity<ImResult<ImResult.AuthRes>> responseEntity = restTemplate.exchange(
            loginUrl,
            HttpMethod.POST,
            entity,
            new ParameterizedTypeReference<ImResult<ImResult.AuthRes>>() {}
        );

        ImResult<ImResult.AuthRes> resp = responseEntity.getBody();

        if (resp == null || resp.getCode() != 200) {
            throw new RuntimeException("刷新 IM Token 失败:" + (resp != null ? resp.getMsg() : "无响应"));
        }

        String token = resp.getData().getAccess_token();
        long expireSec = resp.getData().getExpire_in();

        // 提前 60 秒刷新
        long cacheExpire = Math.max(expireSec - 60, 30);
        redisTemplate.opsForValue().set(TOKEN_KEY, token, cacheExpire, TimeUnit.SECONDS);

        log.info("IM Token 刷新成功,有效期 {} 秒", expireSec);
        return token;
    }

    /** 用于接口调用失败自动刷新 token 并重试一次 */
    public <T> T callWithRetry(IMApiCall<T> call) {
        T result = call.execute(getIMToken());

        // 判断返回对象是否包含 code=401
        if (result instanceof Map) {
            Map<?, ?> map = (Map<?, ?>) result;
            Object code = map.get("code");
            if ("401".equals(String.valueOf(code))) {
                log.warn("IM Token 失效,刷新后重试接口调用");
                refreshTokenWithLock();
                result = call.execute(getIMToken());
            }
        }

        return result;
    }

    /** 接口调用函数式接口 */
    @FunctionalInterface
    public interface IMApiCall<T> {
        T execute(String token);
    }
}

3. 客户端封装

IMClient 封装 GET/POST 请求,自动带 Token 和 clientid Header,并支持参数传递。

java 复制代码
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestTemplate;
import org.springframework.web.util.UriComponentsBuilder;

import java.util.Map;

/**
 * @Author: zr
 * @Date: 2025/12/02/10:51
 * @Description:
 */
@Component
@RequiredArgsConstructor
@Slf4j
public class IMClient {

    private final ImAuthProperties properties;
    private final IMTokenManager tokenManager;

    private final RestTemplate restTemplate = new RestTemplate();

    /**
     * GET 请求(自动刷新 token 并重试一次)
     */
    public <T> T get(String path, Map<String, Object> params, Class<T> responseType) {
        return tokenManager.callWithRetry(token -> {
            // 构建带参数的 URL
            UriComponentsBuilder builder = UriComponentsBuilder.fromHttpUrl(properties.getBaseUrl() + path);
            if (params != null) {
                params.forEach((k, v) -> builder.queryParam(k, v));
            }
            String url = builder.toUriString();

            HttpHeaders headers = new HttpHeaders();
            headers.setBearerAuth(token);
            headers.add("clientid", properties.getClientId()); // 添加 clientid Header

            HttpEntity<Void> entity = new HttpEntity<>(headers);

            // 打印调试信息
            log.info("IM GET 请求 URL: {}", url);
            log.info("IM GET 请求 Headers: {}", headers);

            T response = restTemplate.exchange(url, HttpMethod.GET, entity, responseType).getBody();

            log.info("IM GET Response: {}", response);
            return response;
        });
    }

    /**
     * POST 请求(自动刷新 token 并重试一次)
     */
    public <T> T post(String path, Object body, Class<T> responseType) {
        return tokenManager.callWithRetry(token -> {
            String url = properties.getBaseUrl() + path;
            HttpHeaders headers = new HttpHeaders();
            headers.setContentType(MediaType.APPLICATION_JSON);
            headers.setBearerAuth(token);
            headers.add("clientid", properties.getClientId());
            HttpEntity<Object> entity = new HttpEntity<>(body, headers);
            // 打印请求信息
            log.info("IM POST 请求 URL: {}", url);
            log.info("IM POST 请求 Headers: {}", headers);
            log.info("IM POST 请求 Body: {}", body);
            T response = restTemplate.postForObject(url, entity, responseType);
            // 打印响应
            log.info("IM POST Response: {}", response);
            return response;
        });
    }

    /**
     * 获取当前有效的 IM Access Token
     */
    public String getToken() {
        return tokenManager.getIMToken();
    }
}

4. 返回结果封装

java 复制代码
package cn.iocoder.yudao.framework.common.im.vo;

import lombok.Data;

/**
 * @Author: zr
 * @Date: 2025/12/02/10:49
 * @Description:
 */
@Data
public class ImResult <T>{
    private Integer code;
    private String msg;
    private T data;

    /**
     * 认证返回的 Data
     */
    @Data
    public static class AuthRes {
        private String access_token;
        private Integer expire_in;
        private String client_id;
        private String openid;
        private String refresh_token;
        private Integer refresh_expire_in;
        private String scope;
    }
}

5. 使用示例

java 复制代码
@Resource
private IMClient imClient;

@Test
public void testGet() {
    // 调用任意 GET 接口
    String path = "/im/group/list"; // 替换为实际接口
    Map response = imClient.get(path,null, Map.class);
}

6. 接口调用失败自动刷新 Token 并重试演示

Redis 缓存的 token 还在有效期内,但它本身已经失效 (比如被后台强制作废、用户密码/权限变更、IM 服务端回收等)。单纯依赖过期时间无法保证 token 一定有效,所以需要在 IMClientIMTokenManager 中增加 "失败重试 + 刷新 token"机制

  1. 修改redis中token的最后一位从E改为Q, 人为的制造token过期的异常情况
  1. 可以看到请求结果一次是token认证异常,会自动刷新 Token 并重试

7. 核心特点

  1. Redis 缓存 Token,减少重复登录。
  2. 分布式锁 防止并发刷新。
  3. 接口调用失败自动刷新 Token 并重试一次
  4. GET/POST 请求封装 ,自动带 Token 和 clientid
  5. 支持返回对象泛型,可处理 JSON、Map 或自定义对象。

这套方案通用性很强,适合调用任何需要 Token 鉴权的系统接口,不局限于 IM 系统。

2. 把 IMClient 抽取为 Spring Boot Starter (可选)

0. 源码地址

zr/springboot-box-im-starter:

1. 新建一个maven的starter项目

项目结构如下:把第一部分的代码按照结构整理即可

2. pom.xml 配置

Starter 需要暴露给 Spring Boot 自动装配:

xml 复制代码
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
  xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
  <modelVersion>4.0.0</modelVersion>
  <parent>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-parent</artifactId>
    <version>3.2.2</version>
  </parent>
  <groupId>site.zr.boot</groupId>
  <artifactId>springboot-box-im-starter</artifactId>
  <version>1.0-SNAPSHOT</version>
  <packaging>jar</packaging>


  <name>springboot-box-im-starter</name>
  <url>http://maven.apache.org</url>

  <properties>
    <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
    <java.version>17</java.version>
    <spring.boot.version>3.2.2</spring.boot.version>
  </properties>

  <dependencies>

    <!-- Spring Boot Web -->
    <dependency>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
    <!-- Redis 支持 -->
    <dependency>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-starter-data-redis</artifactId>
    </dependency>
    <!-- Spring Boot 核心依赖 -->
    <dependency>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-starter</artifactId>
    </dependency>
    <!-- Spring Boot 自动装配支持 -->
    <dependency>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-autoconfigure</artifactId>
    </dependency>
    <!-- Spring Boot 配置处理器(可生成元信息用于 IDE 自动补全 @ConfigurationProperties) -->
    <dependency>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-configuration-processor</artifactId>
      <version>${spring.boot.version}</version>
      <optional>true</optional>
    </dependency>
    <dependency>
      <groupId>org.projectlombok</groupId>
      <artifactId>lombok</artifactId>
      <version>1.18.30</version>
      <scope>provided</scope>
    </dependency>
    <!-- Hutool 核心工具包(集合、字符串、日期等工具类) -->
    <dependency>
      <groupId>cn.hutool</groupId>
      <artifactId>hutool-core</artifactId>
      <version>5.8.38</version>
    </dependency>
    <!-- Hutool HTTP 模块,简化 GET/POST 请求 -->
    <dependency>
      <groupId>cn.hutool</groupId>
      <artifactId>hutool-http</artifactId>
      <version>5.8.38</version>
    </dependency>
  </dependencies>
</project>

3. 自动装配类

java 复制代码
package site.zr.boot.config;

import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.core.RedisTemplate;
import site.zr.boot.IMClient;
import site.zr.boot.IMTokenManager;
import site.zr.boot.ImAuthProperties;

/**
 * @Author: zr
 * @Date: 2025/12/02/15:04
 * @Description:
 */
@Configuration
@EnableConfigurationProperties(ImAuthProperties.class)
public class IMAutoConfiguration {

    @Bean
    @ConditionalOnMissingBean
    public IMTokenManager imTokenManager(ImAuthProperties properties, RedisTemplate<String, String> redisTemplate) {
        return new IMTokenManager(properties, redisTemplate);
    }

    @Bean
    @ConditionalOnMissingBean
    public IMClient imClient(ImAuthProperties properties, IMTokenManager tokenManager) {
        return new IMClient(properties, tokenManager);
    }
}

4. 声明自动配置类(我用的Spring Boot 3/4 的方式)

⚠️ 注意事项:Spring Boot 3/4 与旧版自动配置方式的区别

在 Spring Boot 2.x 及之前版本,第三方 Starter 通常通过在 META-INF/spring.factories 文件中声明自动配置类,实现 @EnableAutoConfiguration 自动装配。例如:

properties 复制代码
# Spring Boot 2.x 的方式
org.springframework.boot.autoconfigure.EnableAutoConfiguration=\
site.zr.boot.config.IMAutoConfiguration

然而,从 Spring Boot 3.x(以及未来 4.x) 开始,这种方式已经被 **META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports** 文件替代。新的机制更安全、灵活,也更适合模块化设计:

properties 复制代码
# Spring Boot 3/4 的方式
site.zr.boot.config.IMAutoConfiguration

同时要注意:

  1. **自动配置类必须加上 @Configuration 和 **@EnableConfigurationProperties
plain 复制代码
@Configuration
@EnableConfigurationProperties(ImAuthProperties.class)
public class IMAutoConfiguration { ... }
  1. 不要再使用 spring.factories 文件,否则在新版本的 Spring Boot 中可能会失效或者出现 Bean 创建异常。
  2. Starter 项目依赖必须和 Spring Boot 主项目版本一致 ,否则可能导致 NoSuchMethodError(比如 UriComponentsBuilder.fromHttpUrl 方法在旧版与新版不兼容)。
  3. 尽量使用 Spring Boot 推荐的 spring-boot-starter-*** 依赖管理,避免手动引入低版本依赖冲突。

💡 小结:

如果你的 Starter 是为 Spring Boot 3.x 或 4.x 设计的,一定要采用 AutoConfiguration.imports + @EnableConfigurationProperties 的新方式,而不是沿用 2.x 的 spring.factories

5. 打包并安装至本地maven仓库

install的时候同时也会package 所以会生成jar

install 会将生成的jar 包放进本地maven仓库如下图所示

6. 使用示例

  1. 新建一个springboot项目引入我们的starter,注意,必须要install才能通过如下maven的方式导入包
  2. 在使用方项目中:
xml 复制代码
<dependency>
  <groupId>site.zr.boot</groupId>
  <artifactId>springboot-box-im-starter</artifactId>
  <version>1.0-SNAPSHOT</version>
</dependency>
  1. YAML 配置:
yaml 复制代码
spring:
  data:
    redis:
      host: 192.168.1.130 # 地址
      port: 3306 # 端口
      database: 14 # 数据库索引
      password: xxxxxxx # 密码,建议生产环境开启
im:
  auth:
    base-url: http://127.0.0.1:8889
    tenant-id: 000000
    username: admin
    password: xxxxxxxx
    client-id: xxxxxxxxxxxxxxxxxx
    grant-type: password
  1. 创建测试类,然后直接注入:
java 复制代码
package site.zr.boot.test;

import jakarta.annotation.Resource;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;
import site.zr.boot.IMClient;

import java.util.Map;

@SpringBootTest(classes = TestApplication.class)
class TestApplicationTests {
    @Resource
    private IMClient imClient;

    @Test
    void contextLoads() {
        // 调用任意 GET 接口
        String path = "/im/group/list"; // 替换为实际接口
        Map response = imClient.get(path,null, Map.class);
    }

}
  1. 无需再手动创建 IMClientIMTokenManager,完全自动装配。
相关推荐
葫芦和十三3 小时前
图解 MongoDB 21|选举与 failover:Primary 是怎么选出来的
后端·mongodb·agent
GetcharZp4 小时前
26k Star 开源内网穿透神器 NetBird,一分钟实现全球设备互联!
后端
考虑考虑4 小时前
Mybatis实现批量插入
java·后端·mybatis
咖啡八杯5 小时前
GoF设计模式——中介者模式
java·后端·spring·设计模式
lizhongxuan7 小时前
多Agent之间的区别
后端
青石路9 小时前
记一次多JDK版本问题的排查,一坑套一坑,差点没爬上来
java
杨充9 小时前
1.面向对象设计思想
后端
IT_陈寒10 小时前
Java的Date类又坑了我一次,改用时间戳真香
前端·人工智能·后端
systemPro10 小时前
2.6亿条设备数据,历史查询从超时到50ms,我做了什么
后端
要阿尔卑斯吗10 小时前
提示词优化启示:为什么“按顺序输出“比“关键度评分“更有效
后端