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,完全自动装配。
相关推荐
前端fighter33 分钟前
全栈项目:闲置二手交易系统(一)
前端·vue.js·后端
疯狂的程序猴34 分钟前
Fastlane 结合 开心上架,构建跨平台可发布的 iOS 自动化流水线实践
后端
卷到起飞的数分38 分钟前
19.Spring Boot原理1
java·spring boot·后端
小周在成长38 分钟前
Java 自定义异常
后端
消失的旧时光-194340 分钟前
彻底理解 synchronized:实例锁、类锁与自定义锁的原理和最佳实践
java·开发语言
鹿里噜哩40 分钟前
Spring Authorization Server 打造认证中心(二)自定义数据库表
spring boot·后端·kotlin
Lovely_Ruby1 小时前
前端er Go-Frame 的学习笔记:实现 to-do 功能(一)
前端·后端
喵个咪1 小时前
初学者导引:在 Go-Kratos 中用 go-crud 实现 Ent ORM CRUD 操作
后端·go
开源之眼1 小时前
github star 较多的Java双亲委派机制【类加载的核心内容加星】
java