当前位置: 首页 > news >正文

深度剖析:Feign 调用第三方接口 + Token 自动续期(24 小时有效期 + 1/4 时间触发)实战指南

一、引言:为什么要解决 Token 续期问题?

在微服务架构或系统集成场景中,调用第三方接口是高频需求。多数第三方接口会采用 Token 认证机制,要求请求携带有效 Token 才能访问。若 Token 过期后未及时续期,会导致接口调用失败,进而引发业务中断 —— 比如订单同步、数据查询等核心流程停滞。

二、核心技术栈解析

在开始实现前,先明确方案依赖的核心技术及版本选型,确保环境兼容性和稳定性。所有组件均选用 2024 年最新稳定版本,避免因版本陈旧导致的兼容性问题。

技术组件版本号核心作用
JDK17基础开发环境,提供 Lambda、Record 等特性支持
Spring Boot3.2.5快速构建 Spring 应用,简化配置
Spring Cloud OpenFeign4.1.1声明式 HTTP 客户端,简化第三方接口调用
Lombok1.18.30减少模板代码(如 getter/setter、日志),提高开发效率
FastJSON22.0.49JSON 序列化 / 反序列化工具,性能优于传统 JSON 库
Redis7.2.4存储 Token 及过期时间,支持分布式锁控制并发
Spring Data Redis3.2.5简化 Redis 操作
JJWT0.12.5解析 JWT 格式 Token,提取过期时间等核心信息
MyBatis-Plus3.5.5简化 MySQL 数据库操作,用于 Token 备份存储(避免 Redis 宕机丢失)
MySQL8.0.36持久化存储 Token 备份数据
SpringDoc-OpenAPI2.3.0实现 Swagger3 接口文档,方便接口测试
Guava33.2.1提供高效集合工具(如 Lists、Maps),简化集合操作

关键技术答疑

  1. 为什么用 Feign 而非 RestTemplate?Feign 是声明式 API,只需定义接口并添加注解即可实现 HTTP 调用,无需手动封装请求参数、处理响应,代码更简洁且易维护。同时默认集成负载均衡(Spring Cloud LoadBalancer),若后续第三方接口集群化部署,无需修改代码即可支持。

  2. 为什么选择 Redis 存储 Token?Token 需高频读取(每次 Feign 调用前都要获取),Redis 的内存存储特性可提供毫秒级读取速度;此外 Redis 支持设置 Key 过期时间,可与 Token 有效期同步,避免手动维护过期状态。

  3. 为什么要做 MySQL 备份?考虑到 Redis 是内存数据库,若发生宕机且未开启持久化,Token 会丢失。将 Token 备份到 MySQL,可在 Redis 恢复后从数据库同步数据,保证服务连续性。

三、准备工作:搭建基础项目

3.1 项目结构设计

先明确项目目录结构,确保代码分层清晰,符合 “高内聚、低耦合” 原则。

com.example.feignauth
├── FeignAuthApplication.java       // 启动类
├── config                          // 配置类
│   ├── FeignConfig.java            // Feign自定义配置(日志、超时)
│   ├── RedisConfig.java            // Redis配置
│   ├── MyBatisPlusConfig.java      // MyBatis-Plus配置
│   └── SwaggerConfig.java         // Swagger3配置
├── dto                             // 数据传输对象
│   ├── request                     // 请求DTO
│   │   ├── LoginRequestDTO.java    // 登录请求DTO
│   │   └── TokenRenewRequestDTO.java // Token续期请求DTO
│   └── response                    // 响应DTO
│       ├── LoginResponseDTO.java   // 登录响应DTO(含Token)
│       └── TokenRenewResponseDTO.java // 续期响应DTO
├── feign                           // Feign客户端
│   └── ThirdPartyAuthFeignClient.java // 第三方认证接口Feign客户端
├── interceptor                     // 拦截器
│   └── FeignAuthInterceptor.java   // Feign请求拦截器(注入Token+续期)
├── mapper                          // MyBatis-Plus mapper接口
│   └── TokenMapper.java            // Token备份Mapper
├── entity                          // 数据库实体
│   └── TokenEntity.java            // Token备份实体
├── service                         // 业务服务
│   ├── TokenStorageService.java    // Token存储服务(Redis+MySQL)
│   ├── TokenRenewalService.java    // Token续期服务
│   ├── AuthService.java            // 认证服务(登录、调用第三方接口)
│   └── impl                        // 服务实现
│       ├── TokenStorageServiceImpl.java
│       ├── TokenRenewalServiceImpl.java
│       └── AuthServiceImpl.java
└── util                            // 工具类├── TokenParser.java            // Token解析工具(提取过期时间)├── RedisDistributedLock.java   // Redis分布式锁(控制续期并发)└── DateUtils.java              // 日期工具类(计算剩余时间)

3.2 依赖配置(pom.xml)

pom.xml中引入所有核心依赖,注意版本兼容性(已验证以下版本可正常运行)。

<?xml version="1.0" encoding="UTF-8"?>
<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 https://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.5</version><relativePath/> <!-- lookup parent from repository --></parent><groupId>com.example</groupId><artifactId>feign-auth-token-renewal</artifactId><version>0.0.1-SNAPSHOT</version><name>feign-auth-token-renewal</name><description>Feign调用第三方接口+Token自动续期示例</description><properties><java.version>17</java.version><spring-cloud.version>2023.0.1</spring-cloud.version><mybatis-plus.version>3.5.5</mybatis-plus.version><fastjson2.version>2.0.49</fastjson2.version><jjwt.version>0.12.5</jjwt.version><guava.version>33.2.1</guava.version><springdoc-openapi.version>2.3.0</springdoc-openapi.version></properties><dependencies><!-- Spring Boot Web --><dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-web</artifactId></dependency><!-- Spring Cloud OpenFeign --><dependency><groupId>org.springframework.cloud</groupId><artifactId>spring-cloud-starter-openfeign</artifactId></dependency><!-- Spring Cloud LoadBalancer(Feign默认依赖) --><dependency><groupId>org.springframework.cloud</groupId><artifactId>spring-cloud-starter-loadbalancer</artifactId></dependency><!-- Lombok --><dependency><groupId>org.projectlombok</groupId><artifactId>lombok</artifactId><version>1.18.30</version><scope>provided</scope></dependency><!-- FastJSON2 --><dependency><groupId>com.alibaba.fastjson2</groupId><artifactId>fastjson2</artifactId><version>${fastjson2.version}</version></dependency><!-- Redis --><dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-data-redis</artifactId></dependency><!-- JJWT(解析JWT Token) --><dependency><groupId>io.jsonwebtoken</groupId><artifactId>jjwt-api</artifactId><version>${jjwt.version}</version></dependency><dependency><groupId>io.jsonwebtoken</groupId><artifactId>jjwt-impl</artifactId><version>${jjwt.version}</version><scope>runtime</scope></dependency><dependency><groupId>io.jsonwebtoken</groupId><artifactId>jjwt-jackson</artifactId><version>${jjwt.version}</version><scope>runtime</scope></dependency><!-- MyBatis-Plus --><dependency><groupId>com.baomidou</groupId><artifactId>mybatis-plus-boot-starter</artifactId><version>${mybatis-plus.version}</version></dependency><!-- MySQL Driver --><dependency><groupId>com.mysql</groupId><artifactId>mysql-connector-j</artifactId><scope>runtime</scope></dependency><!-- Swagger3(SpringDoc-OpenAPI) --><dependency><groupId>org.springdoc</groupId><artifactId>springdoc-openapi-starter-webmvc-ui</artifactId><version>${springdoc-openapi.version}</version></dependency><!-- Guava --><dependency><groupId>com.google.guava</groupId><artifactId>guava</artifactId><version>${guava.version}</version></dependency><!-- Spring Boot Test --><dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-test</artifactId><scope>test</scope></dependency></dependencies><!-- Spring Cloud 依赖管理 --><dependencyManagement><dependencies><dependency><groupId>org.springframework.cloud</groupId><artifactId>spring-cloud-dependencies</artifactId><version>${spring-cloud.version}</version><type>pom</type><scope>import</scope></dependency></dependencies></dependencyManagement><build><plugins><plugin><groupId>org.springframework.boot</groupId><artifactId>spring-boot-maven-plugin</artifactId><configuration><excludes><exclude><groupId>org.projectlombok</groupId><artifactId>lombok</artifactId></exclude></excludes></configuration></plugin></plugins></build>
</project>

3.3 配置文件(application.yml)

配置服务端口、第三方接口地址、Redis、MySQL、Feign、Swagger 等核心参数,所有配置均支持动态修改(生产环境建议用 Nacos 配置中心)。

# 服务基础配置
server:port: 8080servlet:context-path: /feign-auth# 第三方接口配置
third-party:auth:url: http://localhost:8090  # 本地部署的第三方接口地址login-path: /api/auth/login # 登录接口路径renew-path: /api/auth/renew # 续期接口路径client-id: feign-auth-client # 第三方接口分配的客户端IDclient-secret: XXXX-XXXX-XXXX-XXXX # 客户端密钥# Spring配置
spring:# Redis配置data:redis:host: localhostport: 6379password: 123456database: 0timeout: 5000mslettuce:pool:max-active: 8max-idle: 8min-idle: 2max-wait: 1000ms# MySQL配置datasource:driver-class-name: com.mysql.cj.jdbc.Driverurl: jdbc:mysql://localhost:3306/feign_auth_db?useUnicode=true&characterEncoding=utf-8&useSSL=false&serverTimezone=GMT%2B8username: rootpassword: 123456# Feign配置
feign:client:config:default: # 全局配置(也可针对单个Feign客户端配置)logger-level: FULL # 日志级别(BASIC:请求方法+URL+状态码;FULL:完整请求响应)connect-timeout: 5000 # 连接超时时间(ms)read-timeout: 10000 # 读取超时时间(ms)compression:request:enabled: true # 开启请求压缩mime-types: application/json,application/xml,text/plainmin-request-size: 2048 # 最小压缩阈值(字节)response:enabled: true # 开启响应压缩# MyBatis-Plus配置
mybatis-plus:mapper-locations: classpath:mapper/**/*.xmltype-aliases-package: com.example.feignauth.entityconfiguration:map-underscore-to-camel-case: true # 下划线转驼峰log-impl: org.apache.ibatis.logging.stdout.StdOutImpl # 日志打印global-config:db-config:id-type: auto # 主键自增table-prefix: t_ # 表前缀# Swagger3配置
springdoc:api-docs:path: /api-docs # API文档JSON路径enabled: trueswagger-ui:path: /swagger-ui.html # Swagger UI页面路径operationsSorter: method # 接口排序方式(method:按HTTP方法排序)packages-to-scan: com.example.feignauth.controller # 扫描的Controller包

3.4 启动类(FeignAuthApplication.java)

添加@EnableFeignClients开启 Feign 功能,@MapperScan扫描 MyBatis-Plus Mapper 接口,@OpenAPIDefinition配置 Swagger 文档基础信息。

package com.example.feignauth;import io.swagger.v3.oas.annotations.OpenAPIDefinition;
import io.swagger.v3.oas.annotations.info.Contact;
import io.swagger.v3.oas.annotations.info.Info;
import io.swagger.v3.oas.annotations.info.License;
import org.mybatis.spring.annotation.MapperScan;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cloud.openfeign.EnableFeignClients;/*** 项目启动类** @author ken*/
@SpringBootApplication
@EnableFeignClients(basePackages = "com.example.feignauth.feign") // 扫描Feign客户端
@MapperScan(basePackages = "com.example.feignauth.mapper") // 扫描MyBatis-Plus Mapper
@OpenAPIDefinition(info = @Info(title = "Feign调用第三方接口+Token续期API文档",description = "包含登录、Token续期、第三方接口调用等功能",version = "1.0.0",contact = @Contact(name = "技术团队", email = "tech@example.com"),license = @License(name = "Apache 2.0", url = "https://www.apache.org/licenses/LICENSE-2.0.html"))
)
public class FeignAuthApplication {public static void main(String[] args) {SpringApplication.run(FeignAuthApplication.class, args);}}

四、核心数据模型设计(DTO+Entity)

4.1 请求 DTO(登录 + 续期)

4.1.1 登录请求 DTO(LoginRequestDTO.java)

封装第三方登录接口所需参数,添加 Swagger 注解说明字段含义,并用StringUtils.hasText做参数校验。

package com.example.feignauth.dto.request;import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import org.springframework.util.StringUtils;import java.io.Serializable;/*** 登录请求DTO** @author ken*/
@Data
@Schema(description = "登录请求参数")
public class LoginRequestDTO implements Serializable {private static final long serialVersionUID = 1L;@Schema(description = "客户端ID", requiredMode = Schema.RequiredMode.REQUIRED, example = "feign-auth-client")private String clientId;@Schema(description = "客户端密钥", requiredMode = Schema.RequiredMode.REQUIRED, example = "XXXX-XXXX-XXXX-XXXX")private String clientSecret;/*** 参数校验** @throws IllegalArgumentException 当参数为空时抛出异常*/public void validate() {StringUtils.hasText(clientId, "客户端ID不能为空");StringUtils.hasText(clientSecret, "客户端密钥不能为空");}}
4.1.2 续期请求 DTO(TokenRenewRequestDTO.java)

封装 Token 续期接口所需参数,需携带当前有效 Token。

package com.example.feignauth.dto.request;import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import org.springframework.util.StringUtils;import java.io.Serializable;/*** Token续期请求DTO** @author ken*/
@Data
@Schema(description = "Token续期请求参数")
public class TokenRenewRequestDTO implements Serializable {private static final long serialVersionUID = 1L;@Schema(description = "当前有效Token", requiredMode = Schema.RequiredMode.REQUIRED, example = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...")private String accessToken;@Schema(description = "客户端ID", requiredMode = Schema.RequiredMode.REQUIRED, example = "feign-auth-client")private String clientId;/*** 参数校验** @throws IllegalArgumentException 当参数为空时抛出异常*/public void validate() {StringUtils.hasText(accessToken, "当前有效Token不能为空");StringUtils.hasText(clientId, "客户端ID不能为空");}}

4.2 响应 DTO(登录 + 续期)

4.2.1 登录响应 DTO(LoginResponseDTO.java)

第三方登录接口返回结果,包含 Token、过期时间(秒)等核心信息。

package com.example.feignauth.dto.response;import com.alibaba.fastjson2.annotation.JSONField;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;import java.io.Serializable;
import java.util.Date;/*** 登录响应DTO** @author ken*/
@Data
@Schema(description = "登录响应结果")
public class LoginResponseDTO implements Serializable {private static final long serialVersionUID = 1L;@Schema(description = "访问Token", example = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...")private String accessToken;@Schema(description = "Token类型", example = "Bearer")private String tokenType;@Schema(description = "过期时间(秒)", example = "86400") // 24小时=86400秒private Integer expiresIn;@Schema(description = "Token颁发时间", example = "2024-05-20 10:00:00")@JSONField(format = "yyyy-MM-dd HH:mm:ss")private Date issuedAt;@Schema(description = "Token过期时间", example = "2024-05-21 10:00:00")@JSONField(format = "yyyy-MM-dd HH:mm:ss")private Date expiresAt;}
4.2.2 续期响应 DTO(TokenRenewResponseDTO.java)

续期接口返回结果,包含新 Token 及新的过期时间。

package com.example.feignauth.dto.response;import com.alibaba.fastjson2.annotation.JSONField;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;import java.io.Serializable;
import java.util.Date;/*** Token续期响应DTO** @author ken*/
@Data
@Schema(description = "Token续期响应结果")
public class TokenRenewResponseDTO implements Serializable {private static final long serialVersionUID = 1L;@Schema(description = "新访问Token", example = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...")private String newAccessToken;@Schema(description = "新Token过期时间(秒)", example = "86400")private Integer newExpiresIn;@Schema(description = "新Token过期时间", example = "2024-05-22 10:00:00")@JSONField(format = "yyyy-MM-dd HH:mm:ss")private Date newExpiresAt;}

4.3 数据库实体(TokenEntity.java)

用于在 MySQL 中备份 Token 数据,避免 Redis 宕机丢失。

package com.example.feignauth.entity;import com.baomidou.mybatisplus.annotation.*;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;import java.io.Serializable;
import java.util.Date;/*** Token备份实体** @author ken*/
@Data
@TableName("t_token_backup")
@Schema(description = "Token备份实体")
public class TokenEntity implements Serializable {private static final long serialVersionUID = 1L;/*** 主键ID*/@TableId(type = IdType.AUTO)@Schema(description = "主键ID", example = "1")private Long id;/*** 客户端ID*/@TableField("client_id")@Schema(description = "客户端ID", example = "feign-auth-client")private String clientId;/*** 访问Token*/@TableField("access_token")@Schema(description = "访问Token", example = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...")private String accessToken;/*** Token过期时间*/@TableField("expires_at")@Schema(description = "Token过期时间", example = "2024-05-21 10:00:00")private Date expiresAt;/*** 创建时间*/@TableField(value = "create_time", fill = FieldFill.INSERT)@Schema(description = "创建时间", example = "2024-05-20 10:00:00")private Date createTime;/*** 更新时间*/@TableField(value = "update_time", fill = FieldFill.INSERT_UPDATE)@Schema(description = "更新时间", example = "2024-05-20 10:00:00")private Date updateTime;/*** 逻辑删除标识(0:未删除,1:已删除)*/@TableLogic@TableField("is_deleted")@Schema(description = "逻辑删除标识", example = "0")private Integer isDeleted;}

4.4 数据库表结构(MySQL)

创建feign_auth_db数据库,并执行以下 SQL 创建t_token_backup表(逻辑删除 + 自动填充时间)。

-- 创建数据库
CREATE DATABASE IF NOT EXISTS feign_auth_db CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;-- 使用数据库
USE feign_auth_db;-- 创建Token备份表
CREATE TABLE IF NOT EXISTS t_token_backup (id BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID',client_id VARCHAR(64) NOT NULL COMMENT '客户端ID',access_token TEXT NOT NULL COMMENT '访问Token',expires_at DATETIME NOT NULL COMMENT 'Token过期时间',create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',is_deleted TINYINT NOT NULL DEFAULT 0 COMMENT '逻辑删除标识(0:未删除,1:已删除)',PRIMARY KEY (id),UNIQUE KEY uk_client_id (client_id) COMMENT '客户端ID唯一索引'
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='Token备份表';

五、Feign 客户端实现:调用第三方接口

5.1 Feign 客户端接口(ThirdPartyAuthFeignClient.java)

定义 Feign 客户端,映射第三方登录和续期接口,通过@FeignClient指定服务名称和基础 URL,@PostMapping映射具体接口路径。

package com.example.feignauth.feign;import com.example.feignauth.dto.request.LoginRequestDTO;
import com.example.feignauth.dto.request.TokenRenewRequestDTO;
import com.example.feignauth.dto.response.LoginResponseDTO;
import com.example.feignauth.dto.response.TokenRenewResponseDTO;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;/*** 第三方认证接口Feign客户端** @author ken*/
@FeignClient(name = "thirdPartyAuthService", // Feign客户端名称(用于负载均衡)url = "${third-party.auth.url}", // 第三方接口基础URL(从配置文件读取)fallbackFactory = ThirdPartyAuthFeignFallbackFactory.class // 降级工厂(可选,增强容错)
)
public interface ThirdPartyAuthFeignClient {/*** 调用第三方登录接口** @param loginRequestDTO 登录请求参数* @return 登录响应结果(含Token)*/@Operation(summary = "第三方登录接口",description = "调用本地第三方接口获取访问Token",responses = {@ApiResponse(responseCode = "200", description = "登录成功",content = @Content(mediaType = MediaType.APPLICATION_JSON_VALUE,schema = @Schema(implementation = LoginResponseDTO.class))),@ApiResponse(responseCode = "400", description = "参数错误"),@ApiResponse(responseCode = "500", description = "服务异常")})@PostMapping(value = "${third-party.auth.login-path}", consumes = MediaType.APPLICATION_JSON_VALUE)LoginResponseDTO login(@Parameter(description = "登录请求参数", required = true)@RequestBody LoginRequestDTO loginRequestDTO);/*** 调用第三方Token续期接口** @param tokenRenewRequestDTO 续期请求参数* @return 续期响应结果(含新Token)*/@Operation(summary = "Token续期接口",description = "调用本地第三方接口刷新Token有效期",responses = {@ApiResponse(responseCode = "200", description = "续期成功",content = @Content(mediaType = MediaType.APPLICATION_JSON_VALUE,schema = @Schema(implementation = TokenRenewResponseDTO.class))),@ApiResponse(responseCode = "401", description = "Token已过期,续期失败"),@ApiResponse(responseCode = "500", description = "服务异常")})@PostMapping(value = "${third-party.auth.renew-path}", consumes = MediaType.APPLICATION_JSON_VALUE)TokenRenewResponseDTO renewToken(@Parameter(description = "续期请求参数", required = true)@RequestBody TokenRenewRequestDTO tokenRenewRequestDTO);}

5.2 Feign 降级处理(ThirdPartyAuthFeignFallbackFactory.java)

为 Feign 客户端添加降级逻辑,当第三方接口不可用时,返回友好提示并记录日志,增强系统容错性。

package com.example.feignauth.feign;import com.example.feignauth.dto.request.LoginRequestDTO;
import com.example.feignauth.dto.request.TokenRenewRequestDTO;
import com.example.feignauth.dto.response.LoginResponseDTO;
import com.example.feignauth.dto.response.TokenRenewResponseDTO;
import feign.hystrix.FallbackFactory;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;/*** 第三方认证Feign客户端降级工厂** @author ken*/
@Component
@Slf4j
public class ThirdPartyAuthFeignFallbackFactory implements FallbackFactory<ThirdPartyAuthFeignClient> {@Overridepublic ThirdPartyAuthFeignClient create(Throwable cause) {return new ThirdPartyAuthFeignClient() {@Overridepublic LoginResponseDTO login(LoginRequestDTO loginRequestDTO) {// 记录降级日志log.error("Feign调用第三方登录接口降级,请求参数:{},异常原因:{}",loginRequestDTO.getClientId(), cause.getMessage(), cause);// 抛出运行时异常(也可返回自定义降级结果,根据业务需求调整)throw new RuntimeException("第三方登录接口暂时不可用,请稍后重试");}@Overridepublic TokenRenewResponseDTO renewToken(TokenRenewRequestDTO tokenRenewRequestDTO) {// 记录降级日志log.error("Feign调用第三方Token续期接口降级,客户端ID:{},异常原因:{}",tokenRenewRequestDTO.getClientId(), cause.getMessage(), cause);// 抛出运行时异常throw new RuntimeException("第三方Token续期接口暂时不可用,请稍后重试");}};}
}

5.3 Feign 配置(FeignConfig.java)

自定义 Feign 配置,设置日志级别、超时时间(也可在application.yml中配置,代码配置优先级更高)。

package com.example.feignauth.config;import feign.Logger;
import feign.Request;
import feign.Retryer;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;/*** Feign客户端配置* 注:若需全局生效,可在@EnableFeignClients中指定defaultConfiguration;若需局部生效,在@FeignClient中指定configuration** @author ken*/
@Configuration
public class FeignConfig {/*** Feign日志级别* 可选值:* - NONE:不记录日志(默认)* - BASIC:记录请求方法、URL、响应状态码* - HEADERS:记录BASIC信息+请求响应头* - FULL:记录完整请求响应(含body)** @return Logger.Level*/@Beanpublic Logger.Level feignLoggerLevel() {return Logger.Level.FULL;}/*** Feign重试机制* 避免因网络抖动导致的调用失败,重试3次(初始间隔1秒,每次间隔翻倍)** @return Retryer*/@Beanpublic Retryer feignRetryer() {return new Retryer.Default(1000, // 初始重试间隔(ms)5000, // 最大重试间隔(ms)3     // 最大重试次数);}/*** Feign超时配置(若application.yml已配置,此配置会覆盖)** @return Request.Options*/@Beanpublic Request.Options feignOptions() {return new Request.Options(5000,  // 连接超时时间(ms)10000  // 读取超时时间(ms));}}

六、Token 存储服务:Redis+MySQL 双备份

6.1 Token 存储服务接口(TokenStorageService.java)

定义 Token 存储、获取、更新、删除的核心方法,兼顾 Redis 缓存和 MySQL 备份。

package com.example.feignauth.service;import com.example.feignauth.dto.response.LoginResponseDTO;
import com.example.feignauth.dto.response.TokenRenewResponseDTO;/*** Token存储服务(Redis+MySQL双备份)** @author ken*/
public interface TokenStorageService {/*** 存储登录返回的Token(Redis+MySQL)** @param clientId         客户端ID* @param loginResponseDTO 登录响应结果(含Token和过期时间)*/void storeToken(String clientId, LoginResponseDTO loginResponseDTO);/*** 更新Token(续期后调用,更新Redis+MySQL)** @param clientId             客户端ID* @param tokenRenewResponseDTO 续期响应结果(含新Token和新过期时间)*/void updateToken(String clientId, TokenRenewResponseDTO tokenRenewResponseDTO);/*** 获取当前有效Token(优先从Redis获取,Redis为空则从MySQL同步)** @param clientId 客户端ID* @return 当前有效Token(若不存在或已过期,返回null)*/String getCurrentToken(String clientId);/*** 获取Token过期时间(毫秒级时间戳)** @param clientId 客户端ID* @return Token过期时间戳(若Token不存在,返回null)*/Long getTokenExpireTime(String clientId);/*** 删除Token(Redis+MySQL)** @param clientId 客户端ID*/void deleteToken(String clientId);/*** 检查Token是否存在且有效** @param clientId 客户端ID* @return true:存在且有效;false:不存在或已过期*/boolean isTokenValid(String clientId);}

6.2 Token 存储服务实现(TokenStorageServiceImpl.java)

实现接口逻辑,Redis 用于高频读取,MySQL 用于持久化备份,确保 Token 不丢失。

package com.example.feignauth.service.impl;import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.example.feignauth.dto.response.LoginResponseDTO;
import com.example.feignauth.dto.response.TokenRenewResponseDTO;
import com.example.feignauth.entity.TokenEntity;
import com.example.feignauth.mapper.TokenMapper;
import com.example.feignauth.service.TokenStorageService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.ObjectUtils;import java.util.Date;
import java.util.concurrent.TimeUnit;/*** Token存储服务实现类** @author ken*/
@Service
@Slf4j
@RequiredArgsConstructor
public class TokenStorageServiceImpl implements TokenStorageService {// Redis Key前缀(避免Key冲突)private static final String REDIS_KEY_PREFIX = "feign_auth:token:";// Token过期时间Key后缀(存储毫秒级时间戳)private static final String REDIS_EXPIRE_TIME_SUFFIX = ":expire_time";private final StringRedisTemplate stringRedisTemplate;private final TokenMapper tokenMapper;@Override@Transactional(rollbackFor = Exception.class)public void storeToken(String clientId, LoginResponseDTO loginResponseDTO) {// 参数校验if (ObjectUtils.isEmpty(clientId) || ObjectUtils.isEmpty(loginResponseDTO)) {log.error("存储Token失败,客户端ID或登录响应为空,clientId:{}", clientId);throw new IllegalArgumentException("客户端ID或登录响应不能为空");}String accessToken = loginResponseDTO.getAccessToken();Date expiresAt = loginResponseDTO.getExpiresAt();if (ObjectUtils.isEmpty(accessToken) || ObjectUtils.isEmpty(expiresAt)) {log.error("存储Token失败,Token或过期时间为空,clientId:{}", clientId);throw new IllegalArgumentException("Token或过期时间不能为空");}// 1. 存储到Redis(设置Key过期时间,与Token过期时间同步)String tokenKey = REDIS_KEY_PREFIX + clientId;String expireTimeKey = tokenKey + REDIS_EXPIRE_TIME_SUFFIX;long expireMillis = expiresAt.getTime() - System.currentTimeMillis(); // 剩余有效时间(毫秒)stringRedisTemplate.opsForValue().set(tokenKey, accessToken, expireMillis, TimeUnit.MILLISECONDS);stringRedisTemplate.opsForValue().set(expireTimeKey, String.valueOf(expiresAt.getTime()), expireMillis, TimeUnit.MILLISECONDS);log.info("Token存储到Redis成功,clientId:{},tokenKey:{},剩余有效时间:{}ms",clientId, tokenKey, expireMillis);// 2. 备份到MySQL(存在则更新,不存在则插入)TokenEntity tokenEntity = tokenMapper.selectOne(new LambdaQueryWrapper<TokenEntity>().eq(TokenEntity::getClientId, clientId).eq(TokenEntity::getIsDeleted, 0));if (ObjectUtils.isEmpty(tokenEntity)) {// 插入新记录tokenEntity = new TokenEntity();tokenEntity.setClientId(clientId);tokenEntity.setAccessToken(accessToken);tokenEntity.setExpiresAt(expiresAt);tokenMapper.insert(tokenEntity);log.info("Token插入MySQL成功,clientId:{},记录ID:{}", clientId, tokenEntity.getId());} else {// 更新现有记录tokenEntity.setAccessToken(accessToken);tokenEntity.setExpiresAt(expiresAt);tokenMapper.updateById(tokenEntity);log.info("Token更新到MySQL成功,clientId:{},记录ID:{}", clientId, tokenEntity.getId());}}@Override@Transactional(rollbackFor = Exception.class)public void updateToken(String clientId, TokenRenewResponseDTO tokenRenewResponseDTO) {// 参数校验if (ObjectUtils.isEmpty(clientId) || ObjectUtils.isEmpty(tokenRenewResponseDTO)) {log.error("更新Token失败,客户端ID或续期响应为空,clientId:{}", clientId);throw new IllegalArgumentException("客户端ID或续期响应不能为空");}String newAccessToken = tokenRenewResponseDTO.getNewAccessToken();Date newExpiresAt = tokenRenewResponseDTO.getNewExpiresAt();if (ObjectUtils.isEmpty(newAccessToken) || ObjectUtils.isEmpty(newExpiresAt)) {log.error("更新Token失败,新Token或新过期时间为空,clientId:{}", clientId);throw new IllegalArgumentException("新Token或新过期时间不能为空");}// 1. 更新RedisString tokenKey = REDIS_KEY_PREFIX + clientId;String expireTimeKey = tokenKey + REDIS_EXPIRE_TIME_SUFFIX;long newExpireMillis = newExpiresAt.getTime() - System.currentTimeMillis();stringRedisTemplate.opsForValue().set(tokenKey, newAccessToken, newExpireMillis, TimeUnit.MILLISECONDS);stringRedisTemplate.opsForValue().set(expireTimeKey, String.valueOf(newExpiresAt.getTime()), newExpireMillis, TimeUnit.MILLISECONDS);log.info("Token更新到Redis成功,clientId:{},新Token:{},新剩余有效时间:{}ms",clientId, newAccessToken.substring(0, 20) + "...", newExpireMillis);// 2. 更新MySQLTokenEntity tokenEntity = tokenMapper.selectOne(new LambdaQueryWrapper<TokenEntity>().eq(TokenEntity::getClientId, clientId).eq(TokenEntity::getIsDeleted, 0));if (ObjectUtils.isEmpty(tokenEntity)) {log.error("更新MySQL Token失败,未找到客户端记录,clientId:{}", clientId);throw new RuntimeException("未找到客户端对应的Token备份记录,无法更新");}tokenEntity.setAccessToken(newAccessToken);tokenEntity.setExpiresAt(newExpiresAt);tokenMapper.updateById(tokenEntity);log.info("Token更新到MySQL成功,clientId:{},记录ID:{}", clientId, tokenEntity.getId());}@Overridepublic String getCurrentToken(String clientId) {if (ObjectUtils.isEmpty(clientId)) {log.error("获取Token失败,客户端ID为空");throw new IllegalArgumentException("客户端ID不能为空");}String tokenKey = REDIS_KEY_PREFIX + clientId;// 1. 优先从Redis获取String accessToken = stringRedisTemplate.opsForValue().get(tokenKey);if (!ObjectUtils.isEmpty(accessToken)) {// 检查Token是否已过期(Redis Key可能因持久化恢复后未过期,但实际Token已过期)Long expireTime = getTokenExpireTime(clientId);if (!ObjectUtils.isEmpty(expireTime) && expireTime > System.currentTimeMillis()) {log.debug("从Redis获取Token成功,clientId:{},Token:{}",clientId, accessToken.substring(0, 20) + "...");return accessToken;} else {log.warn("Redis中的Token已过期,clientId:{}", clientId);deleteToken(clientId); // 删除过期Tokenreturn null;}}// 2. Redis为空,从MySQL同步log.info("Redis中未找到Token,从MySQL同步,clientId:{}", clientId);TokenEntity tokenEntity = tokenMapper.selectOne(new LambdaQueryWrapper<TokenEntity>().eq(TokenEntity::getClientId, clientId).eq(TokenEntity::getIsDeleted, 0));if (ObjectUtils.isEmpty(tokenEntity)) {log.warn("MySQL中也未找到Token记录,clientId:{}", clientId);return null;}// 检查MySQL中的Token是否已过期Date expiresAt = tokenEntity.getExpiresAt();if (ObjectUtils.isEmpty(expiresAt) || expiresAt.before(new Date())) {log.warn("MySQL中的Token已过期,clientId:{}", clientId);deleteToken(clientId); // 删除过期Tokenreturn null;}// 将MySQL中的Token同步到RedisString syncToken = tokenEntity.getAccessToken();long syncExpireMillis = expiresAt.getTime() - System.currentTimeMillis();stringRedisTemplate.opsForValue().set(tokenKey, syncToken, syncExpireMillis, TimeUnit.MILLISECONDS);stringRedisTemplate.opsForValue().set(tokenKey + REDIS_EXPIRE_TIME_SUFFIX,String.valueOf(expiresAt.getTime()), syncExpireMillis, TimeUnit.MILLISECONDS);log.info("从MySQL同步Token到Redis成功,clientId:{},Token:{}",clientId, syncToken.substring(0, 20) + "...");return syncToken;}@Overridepublic Long getTokenExpireTime(String clientId) {if (ObjectUtils.isEmpty(clientId)) {log.error("获取Token过期时间失败,客户端ID为空");throw new IllegalArgumentException("客户端ID不能为空");}String expireTimeKey = REDIS_KEY_PREFIX + clientId + REDIS_EXPIRE_TIME_SUFFIX;String expireTimeStr = stringRedisTemplate.opsForValue().get(expireTimeKey);if (ObjectUtils.isEmpty(expireTimeStr)) {// Redis中无过期时间,从MySQL获取log.debug("Redis中未找到Token过期时间,从MySQL获取,clientId:{}", clientId);TokenEntity tokenEntity = tokenMapper.selectOne(new LambdaQueryWrapper<TokenEntity>().eq(TokenEntity::getClientId, clientId).eq(TokenEntity::getIsDeleted, 0));if (ObjectUtils.isEmpty(tokenEntity) || ObjectUtils.isEmpty(tokenEntity.getExpiresAt())) {log.warn("未找到Token过期时间,clientId:{}", clientId);return null;}return tokenEntity.getExpiresAt().getTime();}try {return Long.parseLong(expireTimeStr);} catch (NumberFormatException e) {log.error("解析Token过期时间失败,clientId:{},过期时间字符串:{}", clientId, expireTimeStr, e);return null;}}@Override@Transactional(rollbackFor = Exception.class)public void deleteToken(String clientId) {if (ObjectUtils.isEmpty(clientId)) {log.error("删除Token失败,客户端ID为空");throw new IllegalArgumentException("客户端ID不能为空");}// 1. 删除Redis中的TokenString tokenKey = REDIS_KEY_PREFIX + clientId;String expireTimeKey = tokenKey + REDIS_EXPIRE_TIME_SUFFIX;stringRedisTemplate.delete(tokenKey);stringRedisTemplate.delete(expireTimeKey);log.info("从Redis删除Token成功,clientId:{}", clientId);// 2. 逻辑删除MySQL中的Token(避免物理删除导致数据丢失)TokenEntity tokenEntity = tokenMapper.selectOne(new LambdaQueryWrapper<TokenEntity>().eq(TokenEntity::getClientId, clientId).eq(TokenEntity::getIsDeleted, 0));if (!ObjectUtils.isEmpty(tokenEntity)) {tokenEntity.setIsDeleted(1);tokenMapper.updateById(tokenEntity);log.info("从MySQL逻辑删除Token成功,clientId:{},记录ID:{}", clientId, tokenEntity.getId());} else {log.warn("MySQL中未找到Token记录,无需删除,clientId:{}", clientId);}}@Overridepublic boolean isTokenValid(String clientId) {// 1. 检查Token是否存在String accessToken = getCurrentToken(clientId);if (ObjectUtils.isEmpty(accessToken)) {return false;}// 2. 检查Token是否未过期Long expireTime = getTokenExpireTime(clientId);if (ObjectUtils.isEmpty(expireTime) || expireTime <= System.currentTimeMillis()) {log.warn("Token已过期,clientId:{}", clientId);deleteToken(clientId);return false;}return true;}
}

七、Token 续期核心逻辑:1/4 有效期触发续期

7.1 核心工具类

7.1.1 Token 解析工具(TokenParser.java)

解析 JWT 格式 Token,提取过期时间(若第三方返回的是普通 Token,需调整解析逻辑,如从响应头或单独接口获取过期时间)。

package com.example.feignauth.util;import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.security.Keys;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import org.springframework.util.ObjectUtils;
import org.springframework.util.StringUtils;import javax.crypto.SecretKey;
import java.nio.charset.StandardCharsets;
import java.util.Date;/*** Token解析工具(针对JWT格式Token)* 注:若第三方返回的是非JWT Token,需替换为对应解析逻辑(如调用第三方获取Token过期时间的接口)** @author ken*/
@Component
@Slf4j
public class TokenParser {/*** JWT密钥(需与第三方接口一致,从配置文件读取)* 若第三方未提供密钥,可忽略签名验证(仅用于解析Claims,不推荐生产环境使用)*/@Value("${third-party.auth.jwt-secret:default-secret-key-for-test}")private String jwtSecret;/*** 解析JWT Token,提取过期时间** @param accessToken JWT格式Token* @return Token过期时间(若解析失败,返回null)*/public Date parseExpireTime(String accessToken) {// 参数校验StringUtils.hasText(accessToken, "Token不能为空");try {// 1. 创建JWT密钥(若第三方未使用签名,可跳过签名验证,使用Jwts.parser().build())SecretKey secretKey = Keys.hmacShaKeyFor(jwtSecret.getBytes(StandardCharsets.UTF_8));// 2. 解析Token,获取Claims(包含exp等字段)Claims claims = Jwts.parser().verifyWith(secretKey) // 验证签名(若无需验证,删除此句).build().parseSignedClaims(accessToken).getPayload();// 3. 提取exp字段(过期时间,毫秒级时间戳)Date expiresAt = claims.getExpiration();if (ObjectUtils.isEmpty(expiresAt)) {log.warn("解析Token未找到exp字段,Token:{}", accessToken.substring(0, 20) + "...");return null;}log.debug("解析Token过期时间成功,Token:{},过期时间:{}",accessToken.substring(0, 20) + "...", expiresAt);return expiresAt;} catch (Exception e) {log.error("解析Token失败,Token:{},异常原因:{}",accessToken.substring(0, 20) + "...", e.getMessage(), e);return null;}}/*** 计算Token剩余有效时间(毫秒)** @param accessToken JWT格式Token* @return 剩余有效时间(毫秒);若Token已过期,返回负数;解析失败返回null*/public Long calculateRemainingTime(String accessToken) {Date expiresAt = parseExpireTime(accessToken);if (ObjectUtils.isEmpty(expiresAt)) {return null;}long currentTime = System.currentTimeMillis();long remainingTime = expiresAt.getTime() - currentTime;log.debug("Token剩余有效时间:{}ms,Token:{}",remainingTime, accessToken.substring(0, 20) + "...");return remainingTime;}/*** 判断Token是否需要续期(剩余时间 <= 1/4总有效期)* 总有效期默认为24小时(86400000毫秒),可从配置文件读取** @param accessToken JWT格式Token* @param totalExpireTime 总有效期(毫秒),默认24小时* @return true:需要续期;false:无需续期;解析失败返回null*/public Boolean needRenew(String accessToken, Long totalExpireTime) {// 默认总有效期:24小时=86400000毫秒long totalExpire = ObjectUtils.isEmpty(totalExpireTime) ? 86400000L : totalExpireTime;// 续期触发阈值:1/4总有效期long renewThreshold = totalExpire / 4;Long remainingTime = calculateRemainingTime(accessToken);if (ObjectUtils.isEmpty(remainingTime)) {return null;}// 剩余时间 <= 阈值 → 需要续期boolean needRenew = remainingTime <= renewThreshold;log.debug("Token是否需要续期:{},剩余时间:{}ms,触发阈值:{}ms",needRenew, remainingTime, renewThreshold);return needRenew;}
}
7.1.2 Redis 分布式锁(RedisDistributedLock.java)

解决续期并发问题:避免多个线程同时检测到 Token 需要续期,导致重复调用第三方续期接口。

package com.example.feignauth.util;import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Component;
import org.springframework.util.ObjectUtils;import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.Lock;/*** Redis分布式锁(基于SET NX EX命令)* 解决Token续期并发问题,避免重复调用续期接口** @author ken*/
@Component
@Slf4j
@RequiredArgsConstructor
public class RedisDistributedLock implements Lock {// Redis锁Key前缀private static final String LOCK_KEY_PREFIX = "feign_auth:distributed_lock:";// 锁默认过期时间(秒):避免死锁,需大于续期接口最大响应时间private static final long DEFAULT_LOCK_EXPIRE = 30;// 锁默认等待时间(秒):获取锁的最大等待时间private static final long DEFAULT_LOCK_WAIT = 10;// 锁默认重试间隔(毫秒):获取锁失败后的重试间隔private static final long DEFAULT_RETRY_INTERVAL = 500;private final StringRedisTemplate stringRedisTemplate;/*** 获取分布式锁(默认参数)** @param lockName 锁名称(如clientId)* @return true:获取成功;false:获取失败*/public boolean lock(String lockName) {return lock(lockName, DEFAULT_LOCK_WAIT, DEFAULT_LOCK_EXPIRE, TimeUnit.SECONDS);}/*** 获取分布式锁(自定义参数)** @param lockName 锁名称* @param waitTime 等待时间* @param expireTime 锁过期时间* @param timeUnit 时间单位* @return true:获取成功;false:获取失败*/public boolean lock(String lockName, long waitTime, long expireTime, TimeUnit timeUnit) {if (ObjectUtils.isEmpty(lockName)) {log.error("获取分布式锁失败,锁名称为空");throw new IllegalArgumentException("锁名称不能为空");}String lockKey = LOCK_KEY_PREFIX + lockName;long waitMillis = timeUnit.toMillis(waitTime);long expireMillis = timeUnit.toMillis(expireTime);long startMillis = System.currentTimeMillis();// 循环获取锁,直到超时while (System.currentTimeMillis() - startMillis <= waitMillis) {// SET NX EX:不存在则设置(原子操作),避免死锁Boolean locked = stringRedisTemplate.opsForValue().setIfAbsent(lockKey,Thread.currentThread().getId() + "", // 存储线程ID,用于释放锁校验expireMillis,TimeUnit.MILLISECONDS);if (Boolean.TRUE.equals(locked)) {log.debug("获取分布式锁成功,lockKey:{},线程ID:{}", lockKey, Thread.currentThread().getId());// 开启锁自动续期(可选,若续期时间长于锁过期时间)// renewLock(lockKey, expireMillis);return true;}// 获取锁失败,等待重试try {Thread.sleep(DEFAULT_RETRY_INTERVAL);log.debug("获取分布式锁失败,等待重试,lockKey:{},线程ID:{}", lockKey, Thread.currentThread().getId());} catch (InterruptedException e) {log.error("获取分布式锁重试时线程被中断,lockKey:{},线程ID:{}", lockKey, Thread.currentThread().getId(), e);Thread.currentThread().interrupt();return false;}}// 超时未获取到锁log.warn("获取分布式锁超时,lockKey:{},等待时间:{}ms,线程ID:{}", lockKey, waitMillis, Thread.currentThread().getId());return false;}/*** 释放分布式锁** @param lockName 锁名称*/public void unlock(String lockName) {if (ObjectUtils.isEmpty(lockName)) {log.error("释放分布式锁失败,锁名称为空");throw new IllegalArgumentException("锁名称不能为空");}String lockKey = LOCK_KEY_PREFIX + lockName;String storedThreadId = stringRedisTemplate.opsForValue().get(lockKey);String currentThreadId = Thread.currentThread().getId() + "";// 校验锁归属:仅允许持有锁的线程释放if (ObjectUtils.isEmpty(storedThreadId) || !storedThreadId.equals(currentThreadId)) {log.warn("释放分布式锁失败,锁不属于当前线程,lockKey:{},存储线程ID:{},当前线程ID:{}",lockKey, storedThreadId, currentThreadId);return;}// 删除锁Boolean deleted = stringRedisTemplate.delete(lockKey);if (Boolean.TRUE.equals(deleted)) {log.debug("释放分布式锁成功,lockKey:{},线程ID:{}", lockKey, currentThreadId);} else {log.warn("释放分布式锁失败,锁已过期或被其他线程删除,lockKey:{},线程ID:{}", lockKey, currentThreadId);}}/*** 锁自动续期(可选)* 适用于续期操作时间可能超过锁过期时间的场景** @param lockKey 锁Key* @param expireMillis 锁过期时间(毫秒)*/private void renewLock(String lockKey, long expireMillis) {// 此处可使用定时任务或线程池,定期延长锁过期时间// 示例:每expireMillis/3毫秒续期一次/*ScheduledExecutorService executorService = Executors.newSingleThreadScheduledExecutor();executorService.scheduleAtFixedRate(() -> {String storedThreadId = stringRedisTemplate.opsForValue().get(lockKey);String currentThreadId = Thread.currentThread().getId() + "";if (!ObjectUtils.isEmpty(storedThreadId) && storedThreadId.equals(currentThreadId)) {stringRedisTemplate.opsForValue().getAndExpire(lockKey, expireMillis, TimeUnit.MILLISECONDS);log.debug("分布式锁自动续期成功,lockKey:{},续期时间:{}ms", lockKey, expireMillis);} else {log.warn("分布式锁自动续期失败,锁已释放或归属变更,lockKey:{}", lockKey);executorService.shutdown();}}, 0, expireMillis / 3, TimeUnit.MILLISECONDS);*/}// 以下方法为Lock接口默认方法,按需实现@Overridepublic void lock() {throw new UnsupportedOperationException("不支持无参lock方法,请使用lock(String lockName)");}@Overridepublic void lockInterruptibly() throws InterruptedException {throw new UnsupportedOperationException("不支持lockInterruptibly方法,请使用lock(String lockName)");}@Overridepublic boolean tryLock() {throw new UnsupportedOperationException("不支持tryLock方法,请使用lock(String lockName)");}@Overridepublic boolean tryLock(long time, TimeUnit unit) throws InterruptedException {throw new UnsupportedOperationException("不支持tryLock(long time, TimeUnit unit)方法,请使用lock(String lockName, long waitTime, long expireTime, TimeUnit timeUnit)");}@Overridepublic void unlock() {throw new UnsupportedOperationException("不支持无参unlock方法,请使用unlock(String lockName)");}@Overridepublic Condition newCondition() {throw new UnsupportedOperationException("不支持newCondition方法");}
}
7.1.3 日期工具类(DateUtils.java)

补全日期工具类代码,完善时间差计算逻辑,确保参数异常时的容错处理。

package com.example.feignauth.util;import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import org.springframework.util.ObjectUtils;import java.text.SimpleDateFormat;
import java.util.Date;
import java.util.TimeZone;/*** 日期工具类** @author ken*/
@Component
@Slf4j
public class DateUtils {// 默认日期格式public static final String DEFAULT_DATE_FORMAT = "yyyy-MM-dd HH:mm:ss";// 时区(默认东八区)public static final TimeZone DEFAULT_TIME_ZONE = TimeZone.getTimeZone("GMT+8");/*** 格式化日期** @param date 日期对象* @return 格式化后的日期字符串(yyyy-MM-dd HH:mm:ss)*/public String format(Date date) {return format(date, DEFAULT_DATE_FORMAT);}/*** 自定义格式格式化日期** @param date    日期对象* @param pattern 格式模板(如yyyy-MM-dd)* @return 格式化后的日期字符串*/public String format(Date date, String pattern) {if (ObjectUtils.isEmpty(date)) {log.warn("格式化日期失败,日期对象为空");return null;}if (ObjectUtils.isEmpty(pattern)) {pattern = DEFAULT_DATE_FORMAT;}try {SimpleDateFormat sdf = new SimpleDateFormat(pattern);sdf.setTimeZone(DEFAULT_TIME_ZONE);return sdf.format(date);} catch (Exception e) {log.error("格式化日期失败,日期:{},格式:{},异常原因:{}", date, pattern, e.getMessage(), e);return null;}}/*** 计算两个日期的时间差(毫秒)** @param endDate   结束日期(后发生的日期)* @param startDate 开始日期(先发生的日期)* @return 时间差(毫秒):endDate - startDate;若参数为空,返回null*/public Long calculateTimeDiff(Date endDate, Date startDate) {if (ObjectUtils.isEmpty(endDate) || ObjectUtils.isEmpty(startDate)) {log.warn("计算时间差失败,日期参数为空,结束日期:{},开始日期:{}", endDate, startDate);return null;}long diff = endDate.getTime() - startDate.getTime();log.debug("计算时间差成功,结束日期:{},开始日期:{},时间差:{}ms",format(endDate), format(startDate), diff);return diff;}/*** 判断目标日期是否已过期(早于当前时间)** @param targetDate 目标日期* @return true:已过期;false:未过期;若参数为空,返回null*/public Boolean isExpired(Date targetDate) {if (ObjectUtils.isEmpty(targetDate)) {log.warn("判断日期是否过期失败,目标日期为空");return null;}Date currentDate = new Date();boolean expired = targetDate.before(currentDate);log.debug("判断日期是否过期:{},目标日期:{},当前日期:{}",expired, format(targetDate), format(currentDate));return expired;}
}

7.2 Token 续期服务

7.2.1 Token 续期服务接口(TokenRenewalService.java)

定义续期核心能力:判断是否需要续期、执行续期操作,明确入参和返回值规范。

package com.example.feignauth.service;/*** Token续期服务* 核心职责:判断Token是否需续期、执行续期逻辑、确保续期并发安全** @author ken*/
public interface TokenRenewalService {/*** 判断Token是否需要续期* 续期规则:Token剩余有效期 ≤ 总有效期的1/4(总有效期默认24小时)** @param clientId 客户端ID(关联唯一Token)* @return true:需要续期;false:无需续期;Token不存在/解析失败返回null*/Boolean isNeedRenewal(String clientId);/*** 执行Token续期操作* 逻辑:调用第三方续期接口 → 获取新Token → 更新Redis+MySQL存储** @param clientId 客户端ID* @return true:续期成功;false:续期失败* @throws RuntimeException 续期过程中发生严重异常(如第三方接口调用失败)*/Boolean doRenewal(String clientId);/*** 检查并自动续期* 组合方法:先判断是否需续期 → 若需续期则执行续期** @param clientId 客户端ID* @return true:无需续期/续期成功;false:续期失败*/Boolean checkAndAutoRenew(String clientId);
}

7.2.2 Token 续期服务实现类(TokenRenewalServiceImpl.java)

核心逻辑实现:集成 Feign 客户端调用第三方接口,用分布式锁控制并发,确保续期操作原子性。

package com.example.feignauth.service.impl;import com.example.feignauth.dto.request.TokenRenewRequestDTO;
import com.example.feignauth.dto.response.TokenRenewResponseDTO;
import com.example.feignauth.feign.ThirdPartyAuthFeignClient;
import com.example.feignauth.service.TokenRenewalService;
import com.example.feignauth.service.TokenStorageService;
import com.example.feignauth.util.RedisDistributedLock;
import com.example.feignauth.util.TokenParser;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.util.ObjectUtils;
import org.springframework.util.StringUtils;import java.util.Date;/*** Token续期服务实现类** @author ken*/
@Service
@Slf4j
@RequiredArgsConstructor
public class TokenRenewalServiceImpl implements TokenRenewalService {// Token总有效期(毫秒):24小时 = 24 * 60 * 60 * 1000 = 86400000ms@Value("${third-party.auth.token-total-expire:86400000}")private Long tokenTotalExpire;// 第三方接口客户端ID(从配置文件读取)@Value("${third-party.auth.client-id}")private String clientId;private final ThirdPartyAuthFeignClient thirdPartyAuthFeignClient;private final TokenStorageService tokenStorageService;private final TokenParser tokenParser;private final RedisDistributedLock distributedLock;@Overridepublic Boolean isNeedRenewal(String clientId) {// 1. 参数校验StringUtils.hasText(clientId, "客户端ID不能为空");// 2. 检查Token是否存在且有效if (!tokenStorageService.isTokenValid(clientId)) {log.warn("判断是否续期失败,Token不存在或已过期,clientId:{}", clientId);return null;}// 3. 获取当前TokenString currentToken = tokenStorageService.getCurrentToken(clientId);if (ObjectUtils.isEmpty(currentToken)) {log.warn("判断是否续期失败,获取当前Token为空,clientId:{}", clientId);return null;}// 4. 调用Token解析工具判断是否需续期return tokenParser.needRenew(currentToken, tokenTotalExpire);}@Overridepublic Boolean doRenewal(String clientId) {// 1. 参数校验StringUtils.hasText(clientId, "客户端ID不能为空");// 2. 分布式锁:避免多线程重复调用续期接口(锁名称=clientId,确保每个客户端锁唯一)String lockName = "token_renewal_" + clientId;try {// 2.1 获取锁:等待10秒,锁过期30秒(需大于续期接口最大响应时间)boolean locked = distributedLock.lock(lockName, 10, 30, java.util.concurrent.TimeUnit.SECONDS);if (!locked) {log.error("执行续期失败,获取分布式锁超时,clientId:{},lockName:{}", clientId, lockName);return false;}// 2.2 二次校验:获取锁后再次检查Token是否已续期(避免锁等待期间其他线程已续期)if (!tokenStorageService.isTokenValid(clientId)) {log.warn("执行续期失败,Token已失效(锁等待期间可能已过期),clientId:{}", clientId);return false;}Boolean needRenew = isNeedRenewal(clientId);if (ObjectUtils.isEmpty(needRenew) || !needRenew) {log.info("无需执行续期,Token剩余时间未达阈值,clientId:{}", clientId);return true;}// 3. 调用第三方续期接口String currentToken = tokenStorageService.getCurrentToken(clientId);TokenRenewRequestDTO renewRequest = new TokenRenewRequestDTO();renewRequest.setAccessToken(currentToken);renewRequest.setClientId(clientId);renewRequest.validate(); // 参数校验log.info("调用第三方续期接口,clientId:{},请求Token:{}",clientId, currentToken.substring(0, 20) + "...");TokenRenewResponseDTO renewResponse = thirdPartyAuthFeignClient.renewToken(renewRequest);// 4. 校验续期响应if (ObjectUtils.isEmpty(renewResponse) || ObjectUtils.isEmpty(renewResponse.getNewAccessToken())) {log.error("执行续期失败,第三方接口返回空响应,clientId:{}", clientId);throw new RuntimeException("第三方续期接口返回无效响应");}if (ObjectUtils.isEmpty(renewResponse.getNewExpiresAt())) {log.error("执行续期失败,新Token过期时间为空,clientId:{},新Token:{}",clientId, renewResponse.getNewAccessToken().substring(0, 20) + "...");throw new RuntimeException("新Token缺少过期时间");}// 5. 更新Token存储(Redis+MySQL)tokenStorageService.updateToken(clientId, renewResponse);log.info("执行续期成功,clientId:{},旧Token:{},新Token:{},新过期时间:{}",clientId,currentToken.substring(0, 20) + "...",renewResponse.getNewAccessToken().substring(0, 20) + "...",new Date(renewResponse.getNewExpiresAt().getTime()));return true;} catch (Exception e) {log.error("执行续期异常,clientId:{},异常原因:{}", clientId, e.getMessage(), e);return false;} finally {// 6. 释放锁(无论成功失败,必须释放)distributedLock.unlock(lockName);}}@Overridepublic Boolean checkAndAutoRenew(String clientId) {try {// 1. 先判断是否需续期Boolean needRenew = isNeedRenewal(clientId);if (ObjectUtils.isEmpty(needRenew)) {log.error("自动续期失败,判断续期状态异常,clientId:{}", clientId);return false;}// 2. 无需续期 → 直接返回成功if (!needRenew) {log.debug("自动续期检查通过,无需续期,clientId:{}", clientId);return true;}// 3. 需续期 → 执行续期return doRenewal(clientId);} catch (Exception e) {log.error("自动续期异常,clientId:{},异常原因:{}", clientId, e.getMessage(), e);return false;}}
}

7.3 Feign 请求拦截器:自动注入 Token 与续期

实现RequestInterceptor接口,在 Feign 请求发送前拦截请求,完成 Token 有效性检查、自动续期、Token 注入请求头的全流程。

package com.example.feignauth.interceptor;import com.example.feignauth.service.TokenRenewalService;
import com.example.feignauth.service.TokenStorageService;
import feign.RequestInterceptor;
import feign.RequestTemplate;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import org.springframework.util.ObjectUtils;
import org.springframework.util.StringUtils;/*** Feign请求拦截器* 作用:1. 请求前检查Token有效性 2. 自动续期 3. 将有效Token注入请求头** @author ken*/
@Component
@Slf4j
@RequiredArgsConstructor
public class FeignAuthInterceptor implements RequestInterceptor {// 第三方接口Token请求头名称(如Authorization)@Value("${third-party.auth.token-header:Authorization}")private String tokenHeader;// Token前缀(如Bearer,根据第三方接口要求配置)@Value("${third-party.auth.token-prefix:Bearer }")private String tokenPrefix;// 客户端ID(与第三方接口约定)@Value("${third-party.auth.client-id}")private String clientId;// 排除拦截的URL(如登录、续期接口,避免循环调用)@Value("${third-party.auth.exclude-urls:/api/auth/login,/api/auth/renew}")private String[] excludeUrls;private final TokenRenewalService tokenRenewalService;private final TokenStorageService tokenStorageService;@Overridepublic void apply(RequestTemplate template) {// 1. 获取当前请求URL,判断是否需要排除拦截String requestUrl = template.url();if (isExcludeUrl(requestUrl)) {log.debug("Feign请求拦截器跳过排除URL,requestUrl:{}", requestUrl);return;}// 2. 自动续期检查(核心:确保Token始终有效)boolean renewSuccess = tokenRenewalService.checkAndAutoRenew(clientId);if (!renewSuccess) {log.error("Feign请求拦截器续期失败,无法注入Token,requestUrl:{},clientId:{}",requestUrl, clientId);throw new RuntimeException("Token续期失败,无法发起请求");}// 3. 获取有效TokenString validToken = tokenStorageService.getCurrentToken(clientId);if (ObjectUtils.isEmpty(validToken)) {log.error("Feign请求拦截器获取Token失败,Token为空,requestUrl:{},clientId:{}",requestUrl, clientId);throw new RuntimeException("获取有效Token失败,无法发起请求");}// 4. 将Token注入请求头(格式:Token前缀 + Token,如Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...)String tokenHeaderValue = tokenPrefix + validToken;template.header(tokenHeader, tokenHeaderValue);log.debug("Feign请求拦截器注入Token成功,requestUrl:{},{}:{}",requestUrl, tokenHeader, tokenHeaderValue.substring(0, 50) + "...");}/*** 判断当前请求URL是否在排除拦截列表中** @param requestUrl Feign请求URL* @return true:排除拦截;false:需要拦截*/private boolean isExcludeUrl(String requestUrl) {if (ObjectUtils.isEmpty(requestUrl) || ObjectUtils.isEmpty(excludeUrls)) {return false;}for (String excludeUrl : excludeUrls) {if (StringUtils.hasText(excludeUrl) && requestUrl.contains(excludeUrl)) {return true;}}return false;}
}

8. 认证服务:登录与第三方接口调用

8.1 认证服务接口(AuthService.java)

定义登录和调用第三方业务接口的核心方法,对外提供统一的认证与接口调用能力。

package com.example.feignauth.service;import com.example.feignauth.dto.response.LoginResponseDTO;/*** 认证服务* 核心职责:1. 初始化登录获取Token 2. 封装第三方业务接口调用(依赖Feign拦截器处理Token)** @author ken*/
public interface AuthService {/*** 初始化登录:调用第三方登录接口,获取Token并存储* 场景:服务启动后首次调用/Token失效后重新登录** @return 登录响应结果(含Token信息)* @throws RuntimeException 登录失败(如第三方接口调用异常、参数错误)*/LoginResponseDTO initLogin();/*** 调用第三方业务接口(示例:获取用户信息)* 注:实际业务需根据第三方接口定义调整入参和返回值** @param userId 用户ID(第三方接口所需参数)* @return 第三方接口返回的用户信息(JSON字符串格式)*/String callThirdPartyUserApi(String userId);
}

8.2 认证服务实现类(AuthServiceImpl.java)

实现登录逻辑(调用 Feign 登录接口 + 存储 Token)和业务接口调用逻辑,依赖 Feign 拦截器自动处理 Token 续期。

package com.example.feignauth.service.impl;import com.example.feignauth.dto.request.LoginRequestDTO;
import com.example.feignauth.dto.response.LoginResponseDTO;
import com.example.feignauth.feign.ThirdPartyAuthFeignClient;
import com.example.feignauth.feign.ThirdPartyBusinessFeignClient;
import com.example.feignauth.service.AuthService;
import com.example.feignauth.service.TokenStorageService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.util.ObjectUtils;/*** 认证服务实现类** @author ken*/
@Service
@Slf4j
@RequiredArgsConstructor
public class AuthServiceImpl implements AuthService {// 第三方接口客户端ID和密钥(从配置文件读取)@Value("${third-party.auth.client-id}")private String clientId;@Value("${third-party.auth.client-secret}")private String clientSecret;private final ThirdPartyAuthFeignClient thirdPartyAuthFeignClient;private final ThirdPartyBusinessFeignClient thirdPartyBusinessFeignClient;private final TokenStorageService tokenStorageService;@Overridepublic LoginResponseDTO initLogin() {log.info("开始执行初始化登录,clientId:{}", clientId);// 1. 构建登录请求参数LoginRequestDTO loginRequest = new LoginRequestDTO();loginRequest.setClientId(clientId);loginRequest.setClientSecret(clientSecret);loginRequest.validate(); // 参数校验// 2. 调用第三方登录接口LoginResponseDTO loginResponse;try {loginResponse = thirdPartyAuthFeignClient.login(loginRequest);} catch (Exception e) {log.error("初始化登录失败,调用第三方登录接口异常,clientId:{},异常原因:{}",clientId, e.getMessage(), e);throw new RuntimeException("第三方登录接口调用失败:" + e.getMessage());}// 3. 校验登录响应if (ObjectUtils.isEmpty(loginResponse) || ObjectUtils.isEmpty(loginResponse.getAccessToken())) {log.error("初始化登录失败,第三方返回Token为空,clientId:{}", clientId);throw new RuntimeException("第三方登录接口返回无效Token");}if (ObjectUtils.isEmpty(loginResponse.getExpiresAt())) {log.error("初始化登录失败,第三方返回Token过期时间为空,clientId:{},Token:{}",clientId, loginResponse.getAccessToken().substring(0, 20) + "...");throw new RuntimeException("第三方登录接口返回Token缺少过期时间");}// 4. 存储Token(Redis+MySQL双备份)tokenStorageService.storeToken(clientId, loginResponse);log.info("初始化登录成功,clientId:{},Token:{},过期时间:{}",clientId,loginResponse.getAccessToken().substring(0, 20) + "...",loginResponse.getExpiresAt());return loginResponse;}@Overridepublic String callThirdPartyUserApi(String userId) {// 1. 参数校验if (ObjectUtils.isEmpty(userId)) {log.error("调用第三方用户接口失败,用户ID为空");throw new IllegalArgumentException("用户ID不能为空");}// 2. 检查Token是否已初始化(首次调用需先登录)if (!tokenStorageService.isTokenValid(clientId)) {log.warn("调用第三方用户接口前Token无效,自动执行初始化登录,clientId:{}", clientId);initLogin(); // 自动登录获取新Token}// 3. 调用第三方业务接口(Feign拦截器自动注入Token)log.info("调用第三方用户接口,userId:{},clientId:{}", userId, clientId);try {String userInfo = thirdPartyBusinessFeignClient.getUserInfo(userId);log.info("调用第三方用户接口成功,userId:{},返回结果长度:{}",userId, ObjectUtils.isEmpty(userInfo) ? 0 : userInfo.length());return userInfo;} catch (Exception e) {log.error("调用第三方用户接口失败,userId:{},clientId:{},异常原因:{}",userId, clientId, e.getMessage(), e);throw new RuntimeException("第三方用户接口调用失败:" + e.getMessage());}}
}

8.3 第三方业务 Feign 客户端(ThirdPartyBusinessFeignClient.java)

定义第三方业务接口的 Feign 客户端(如获取用户信息),与认证 Feign 客户端复用拦截器,自动处理 Token。

package com.example.feignauth.feign;import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;/*** 第三方业务接口Feign客户端* 示例:获取用户信息接口** @author ken*/
@FeignClient(name = "thirdPartyBusinessService",url = "${third-party.auth.url}", // 与认证接口共用基础URLfallbackFactory = ThirdPartyBusinessFeignFallbackFactory.class
)
public interface ThirdPartyBusinessFeignClient {/*** 调用第三方获取用户信息接口** @param userId 用户ID(路径参数)* @return 用户信息(JSON字符串)*/@Operation(summary = "获取用户信息接口",description = "调用第三方业务接口获取用户详情,需Token认证",responses = {@ApiResponse(responseCode = "200", description = "调用成功",content = @Content(mediaType = MediaType.APPLICATION_JSON_VALUE,schema = @Schema(description = "用户信息JSON字符串"))),@ApiResponse(responseCode = "401", description = "Token无效或过期"),@ApiResponse(responseCode = "500", description = "服务异常")})@GetMapping(value = "/api/business/user/{userId}", produces = MediaType.APPLICATION_JSON_VALUE)String getUserInfo(@Parameter(description = "用户ID", required = true, example = "1001")@PathVariable("userId") String userId);
}

8.4 第三方业务 Feign 降级工厂(ThirdPartyBusinessFeignFallbackFactory.java)

业务接口降级处理,避免因第三方业务接口不可用导致本地服务雪崩。

package com.example.feignauth.feign;import feign.hystrix.FallbackFactory;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;/*** 第三方业务Feign客户端降级工厂** @author ken*/
@Component
@Slf4j
public class ThirdPartyBusinessFeignFallbackFactory implements FallbackFactory<ThirdPartyBusinessFeignClient> {@Overridepublic ThirdPartyBusinessFeignClient create(Throwable cause) {return new ThirdPartyBusinessFeignClient() {@Overridepublic String getUserInfo(String userId) {// 记录降级日志log.error("Feign调用第三方用户信息接口降级,userId:{},异常原因:{}",userId, cause.getMessage(), cause);// 返回降级结果(可根据业务需求返回默认值或错误提示)return "{\"code\":503,\"message\":\"第三方用户接口暂时不可用,请稍后重试\",\"data\":null}";}};}
}

9. 核心配置类补全

9.1 Swagger3 配置(SwaggerConfig.java)

基于 SpringDoc-OpenAPI 配置 Swagger3,支持接口文档可视化,便于测试。

package com.example.feignauth.config;import io.swagger.v3.oas.models.OpenAPI;
import io.swagger.v3.oas.models.info.Contact;
import io.swagger.v3.oas.models.info.Info;
import io.swagger.v3.oas.models.info.License;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;/*** Swagger3配置(基于SpringDoc-OpenAPI)* 访问地址:http://localhost:8080/feign-auth/swagger-ui.html** @author ken*/
@Configuration
public class SwaggerConfig {@Beanpublic OpenAPI customOpenAPI() {return new OpenAPI().info(new Info().title("Feign调用第三方接口+Token续期系统API文档").description("包含登录、Token续期、第三方业务接口调用等核心接口").version("1.0.0").contact(new Contact().name("技术团队").email("tech@example.com").url("https://www.example.com")).license(new License().name("Apache 2.0").url("https://www.apache.org/licenses/LICENSE-2.0.html")));}
}

9.2 MyBatis-Plus 配置(MyBatisPlusConfig.java)

配置分页插件、字段自动填充处理器,符合 MyBatis-Plus 最佳实践。

package com.example.feignauth.config;import com.baomidou.mybatisplus.annotation.DbType;
import com.baomidou.mybatisplus.core.handlers.MetaObjectHandler;
import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor;
import com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor;
import org.apache.ibatis.reflection.MetaObject;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;import java.util.Date;/*** MyBatis-Plus配置** @author ken*/
@Configuration
public class MyBatisPlusConfig {/*** 分页插件(支持MySQL、Oracle等多种数据库)*/@Beanpublic MybatisPlusInterceptor mybatisPlusInterceptor() {MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();// 添加MySQL分页插件interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL)// 溢出分页处理:true=直接返回最后一页,false=抛出异常.setOverflow(true));return interceptor;}/*** 字段自动填充处理器* 作用:自动填充createTime、updateTime字段(无需手动设置)*/@Beanpublic MetaObjectHandler metaObjectHandler() {return new MetaObjectHandler() {// 插入时自动填充@Overridepublic void insertFill(MetaObject metaObject) {// 判断是否存在createTime字段if (metaObject.hasSetter("createTime")) {strictInsertFill(metaObject, "createTime", Date.class, new Date());}// 判断是否存在updateTime字段if (metaObject.hasSetter("updateTime")) {strictInsertFill(metaObject, "updateTime", Date.class, new Date());}}// 更新时自动填充@Overridepublic void updateFill(MetaObject metaObject) {// 判断是否存在updateTime字段if (metaObject.hasSetter("updateTime")) {strictUpdateFill(metaObject, "updateTime", Date.class, new Date());}}};}
}

9.3 Redis 配置(RedisConfig.java)

配置 Redis 序列化方式(避免默认 JDK 序列化导致的乱码),自定义 RedisTemplate。

package com.example.feignauth.config;import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer;
import org.springframework.data.redis.serializer.StringRedisSerializer;/*** Redis配置* 核心:配置JSON序列化,避免默认JDK序列化的乱码问题** @author ken*/
@Configuration
public class RedisConfig {@Beanpublic RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory connectionFactory) {RedisTemplate<String, Object> redisTemplate = new RedisTemplate<>();redisTemplate.setConnectionFactory(connectionFactory);// 1. 字符串序列化器(Key用String序列化)StringRedisSerializer stringSerializer = new StringRedisSerializer();redisTemplate.setKeySerializer(stringSerializer);redisTemplate.setHashKeySerializer(stringSerializer);// 2. JSON序列化器(Value用JSON序列化,支持复杂对象)GenericJackson2JsonRedisSerializer jsonSerializer = new GenericJackson2JsonRedisSerializer();redisTemplate.setValueSerializer(jsonSerializer);redisTemplate.setHashValueSerializer(jsonSerializer);// 3. 初始化RedisTemplateredisTemplate.afterPropertiesSet();return redisTemplate;}
}

10. Token 续期流程可视化

通过流程图清晰展示 Feign 请求触发 Token 续期的完整链路,便于理解核心逻辑。


11. 测试验证:确保功能可运行

11.1 环境准备

  1. 基础环境:JDK 17、MySQL 8.0、Redis 7.2.4、Maven 3.9.6
  2. 第三方接口模拟:可使用 Postman Mock Server 或 Spring Boot 编写简易模拟服务,提供登录、续期、用户信息接口:
    • 登录接口(POST /api/auth/login):返回 Token、expiresIn=86400(24 小时)、expiresAt
    • 续期接口(POST /api/auth/renew):接收旧 Token,返回新 Token、新 expiresAt
    • 用户信息接口(GET /api/business/user/{userId}):验证请求头 Token,返回用户 JSON
  3. 数据库初始化:执行前文 4.4 节的 MySQL SQL,创建feign_auth_db库和t_token_backup

11.2 接口测试(Swagger UI)

  1. 启动项目:运行FeignAuthApplication.java,服务端口 8080
  2. 访问 Swagger:打开浏览器访问 http://localhost:8080/feign-auth/swagger-ui.html
  3. 测试初始化登录
    • 找到authService下的initLogin接口,点击「Try it out」→「Execute」
    • 预期结果:返回 200,包含accessTokenexpiresAt(当前时间 + 24 小时)
    • 验证存储:Redis 中存在feign_auth:token:feign-auth-clientfeign_auth:token:feign-auth-client:expire_time,MySQL t_token_backup表新增记录
  4. 测试业务接口调用
    • 找到authService下的callThirdPartyUserApi接口,输入userId=1001,点击执行
    • 预期结果:Feign 拦截器自动检查 Token,无需手动注入,返回用户信息 JSON
  5. 测试 Token 续期触发
    • 模拟 Token 剩余时间≤6 小时(可修改 Redis 中expire_time为当前时间 + 5 小时)
    • 再次调用callThirdPartyUserApi接口
    • 预期结果:拦截器触发续期,Redis 和 MySQL 中的 Token 更新为新值,expiresAt延长 24 小时

11.3 并发续期测试

  1. 工具:使用 JMeter 模拟 10 个线程同时调用callThirdPartyUserApi接口
  2. 观察日志:查看控制台日志,仅 1 个线程成功获取分布式锁执行续期,其他线程等待锁释放后直接使用新 Token
  3. 预期结果:第三方续期接口仅被调用 1 次,无重复续期请求,Redis 中 Token 仅更新 1 次

12. 注意事项与最佳实践

12.1 Token 安全防护

  1. 传输安全:第三方接口通信必须使用 HTTPS,避免 Token 在传输过程中被窃取
  2. 存储安全
    • Redis:开启密码认证、禁止公网访问,敏感场景可加密存储 Token
    • MySQL:access_token字段可加密存储(如 AES 加密),避免明文泄露
  3. 权限控制:限制 Token 的访问范围(如第三方接口按角色分配权限),避免 Token 被盗用后造成大面积损失

12.2 并发控制优化

  1. 分布式锁参数调整
    • 锁等待时间:根据业务接口响应时间设置(如 10-30 秒),避免过长阻塞请求
    • 锁过期时间:需大于续期接口最大响应时间(如 30-60 秒),避免死锁
  2. 锁自动续期:若续期操作耗时较长(如第三方接口响应慢),可在RedisDistributedLock中开启renewLock定时续期功能

12.3 降级与容错设计

  1. Feign 降级:所有 Feign 客户端必须配置fallbackFactory,避免第三方接口不可用时本地服务雪崩
  2. Token 失效降级:若 Token 续期失败且无法登录,可返回默认降级结果或触发告警,避免业务完全中断
  3. 重试机制:Feign 配置重试(如Retryer.Default),应对网络抖动导致的临时调用失败

12.4 监控与告警

  1. 关键指标监控
    • Token 状态:Redis 中 Token 的剩余有效期、MySQL 备份是否同步
    • 续期统计:续期成功 / 失败次数、平均续期耗时
    • 接口调用:Feign 接口调用成功率、响应时间
  2. 告警触发条件
    • 续期失败次数≥3 次
    • Token 剩余有效期≤1 小时(紧急续期)
    • 第三方接口调用失败率≥50%

12.5 配置动态化

  1. 生产环境建议:使用 Nacos/Apollo 配置中心管理以下参数,避免重启服务:
    • 第三方接口地址、clientId、clientSecret
    • Token 总有效期、续期阈值(如 1/4 可改为 1/5)
    • Redis、MySQL 连接信息
    • 排除 URL 列表、Token 请求头名称

13. 常见问题及解决方案

问题现象可能原因解决方案
Feign 调用报 401 Unauthorized1. Token 已过期2. Token 注入失败3. Token 格式错误1. 检查拦截器是否执行续期2. 查看日志确认 Token 是否注入请求头3. 验证 Token 前缀是否符合第三方要求(如是否遗漏 Bearer)
Token 续期失败,提示 “锁等待超时”1. 分布式锁竞争激烈2. 锁等待时间过短3. 前一次续期未释放锁1. 优化锁等待时间(延长至 30 秒)2. 检查是否存在死锁(如续期过程中抛异常未释放锁)3. 增加锁重试间隔(如从 500ms 改为 1000ms)
Redis 宕机后 Token 丢失1. Redis 未开启持久化2. Token 未备份到 MySQL1. Redis 开启 RDB+AOF 持久化2. 确保TokenStorageServicegetCurrentToken方法优先从 MySQL 同步 Token
第三方续期接口返回 “Token 已过期”1. 续期触发过晚(剩余时间≤0)2. 分布式锁等待时间过长1. 调小续期阈值(如从 1/4 改为 1/3)2. 优化锁参数,减少锁等待时间3. 增加 Token 过期前预检查(如定时任务提前续期)
MyBatis-Plus 插入数据时createTime未自动填充1. 实体类字段未加@TableField(fill = FieldFill.INSERT)2. 未配置MetaObjectHandler1. 检查TokenEntitycreateTime字段注解2. 确保MyBatisPlusConfig中注册metaObjectHandlerBean

14. 总结与优化方向

14.1 方案核心亮点

  1. 双存储备份:Redis 高频读取 + MySQL 持久化,确保 Token 不丢失
  2. 智能续期策略:1/4 有效期触发续期,平衡 “提前续期” 与 “减少请求”,避免 Token 过期
  3. 并发安全:分布式锁彻底解决重复续期问题,适合多实例部署场景
  4. 全链路自动化:Feign 拦截器无感处理 Token 续期,业务代码无需关注 Token 逻辑
  5. 高容错性:Feign 降级、自动重连、Token 失效自动登录,提升系统稳定性

14.2 未来优化方向

  1. 多客户端支持:当前方案仅支持单个clientId,可扩展为多客户端管理(如TokenStorageServiceclientId分库分表)
  2. Token 轮换机制:引入刷新 Token(Refresh Token),避免访问 Token 续期失败后需重新登录
  3. 定时任务兜底:增加定时任务(如每小时)检查 Token 状态,避免 Feign 请求未触发续期导致 Token 过期
  4. 熔断降级增强:集成 Sentinel/Hystrix,当第三方接口异常时触发熔断,返回更友好的降级结果
  5. 监控可视化:接入 Grafana+Prometheus,展示 Token 续期成功率、接口调用耗时等指标,支持告警可视化
http://www.dtcms.com/a/496602.html

相关文章:

  • AgentScope RAG 示例指南
  • 做网站学哪种代码好jquery 显示 wordpress
  • 做网站模板的网页名称是m开头swiper wordpress
  • 首京建设投资引导基金网站海淀重庆网站建设
  • NumPy random.choice() 函数详解
  • 网站手机端 怎么做东莞工业品网站建设
  • 广东网站建设网站前端一个页面多少钱
  • Redis分布式锁、Redisson及Redis红锁知识点总结
  • 企业网络建站动漫制作专业专升本大学
  • 东莞网站建设推广方案制作一个网站多少钱啊
  • Spark Shuffle 分区与 AQE 优化
  • 上海住建部网站wordpress下载按钮插件
  • 深度解析:电商API的核心功能与应用
  • 网站建设 定制移动端开发工具
  • html5网站开发费用什么是网络营销?网络营销有哪些功能
  • 衡石 HQL:以函数为基,构建AI时代的敏捷语义层
  • cms网站系统网站建设评审会总结发言
  • 倍数关系:最多能选出多少个数
  • 建设一个怎样的自己的网站首页苏州做网站优化的
  • Kioptrix Level 1渗透测试
  • 中国林业工程建设协会网站企业网站建设的提案
  • 用Vscode编译正点原子ESP32例程报错:ninja: error: loading ‘build.ninja‘: 系统找不到指定的文件
  • 温州专业微网站制作公司哪家好网站开发外包报价
  • 超星网站开发实战答案asp网站安全如何做
  • YOLOv3 核心笔记:多尺度特征融合与全面性能升级
  • 郑州建网站费用快照网站
  • LeetCode 刷题【123. 买卖股票的最佳时机 III】
  • 基于高通跃龙 QCS6490 平台的Sherpa快速部署
  • 赤峰网站建设 公司阿里云建设网站好不好
  • 个人网站备案需要哪些资料网站建立教学