深度剖析:Feign 调用第三方接口 + Token 自动续期(24 小时有效期 + 1/4 时间触发)实战指南
一、引言:为什么要解决 Token 续期问题?
在微服务架构或系统集成场景中,调用第三方接口是高频需求。多数第三方接口会采用 Token 认证机制,要求请求携带有效 Token 才能访问。若 Token 过期后未及时续期,会导致接口调用失败,进而引发业务中断 —— 比如订单同步、数据查询等核心流程停滞。
二、核心技术栈解析
在开始实现前,先明确方案依赖的核心技术及版本选型,确保环境兼容性和稳定性。所有组件均选用 2024 年最新稳定版本,避免因版本陈旧导致的兼容性问题。
技术组件 | 版本号 | 核心作用 |
---|---|---|
JDK | 17 | 基础开发环境,提供 Lambda、Record 等特性支持 |
Spring Boot | 3.2.5 | 快速构建 Spring 应用,简化配置 |
Spring Cloud OpenFeign | 4.1.1 | 声明式 HTTP 客户端,简化第三方接口调用 |
Lombok | 1.18.30 | 减少模板代码(如 getter/setter、日志),提高开发效率 |
FastJSON2 | 2.0.49 | JSON 序列化 / 反序列化工具,性能优于传统 JSON 库 |
Redis | 7.2.4 | 存储 Token 及过期时间,支持分布式锁控制并发 |
Spring Data Redis | 3.2.5 | 简化 Redis 操作 |
JJWT | 0.12.5 | 解析 JWT 格式 Token,提取过期时间等核心信息 |
MyBatis-Plus | 3.5.5 | 简化 MySQL 数据库操作,用于 Token 备份存储(避免 Redis 宕机丢失) |
MySQL | 8.0.36 | 持久化存储 Token 备份数据 |
SpringDoc-OpenAPI | 2.3.0 | 实现 Swagger3 接口文档,方便接口测试 |
Guava | 33.2.1 | 提供高效集合工具(如 Lists、Maps),简化集合操作 |
关键技术答疑
为什么用 Feign 而非 RestTemplate?Feign 是声明式 API,只需定义接口并添加注解即可实现 HTTP 调用,无需手动封装请求参数、处理响应,代码更简洁且易维护。同时默认集成负载均衡(Spring Cloud LoadBalancer),若后续第三方接口集群化部署,无需修改代码即可支持。
为什么选择 Redis 存储 Token?Token 需高频读取(每次 Feign 调用前都要获取),Redis 的内存存储特性可提供毫秒级读取速度;此外 Redis 支持设置 Key 过期时间,可与 Token 有效期同步,避免手动维护过期状态。
为什么要做 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 环境准备
- 基础环境:JDK 17、MySQL 8.0、Redis 7.2.4、Maven 3.9.6
- 第三方接口模拟:可使用 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
- 数据库初始化:执行前文 4.4 节的 MySQL SQL,创建
feign_auth_db
库和t_token_backup
表
11.2 接口测试(Swagger UI)
- 启动项目:运行
FeignAuthApplication.java
,服务端口 8080 - 访问 Swagger:打开浏览器访问
http://localhost:8080/feign-auth/swagger-ui.html
- 测试初始化登录:
- 找到
authService
下的initLogin
接口,点击「Try it out」→「Execute」 - 预期结果:返回 200,包含
accessToken
、expiresAt
(当前时间 + 24 小时) - 验证存储:Redis 中存在
feign_auth:token:feign-auth-client
和feign_auth:token:feign-auth-client:expire_time
,MySQLt_token_backup
表新增记录
- 找到
- 测试业务接口调用:
- 找到
authService
下的callThirdPartyUserApi
接口,输入userId=1001
,点击执行 - 预期结果:Feign 拦截器自动检查 Token,无需手动注入,返回用户信息 JSON
- 找到
- 测试 Token 续期触发:
- 模拟 Token 剩余时间≤6 小时(可修改 Redis 中
expire_time
为当前时间 + 5 小时) - 再次调用
callThirdPartyUserApi
接口 - 预期结果:拦截器触发续期,Redis 和 MySQL 中的 Token 更新为新值,
expiresAt
延长 24 小时
- 模拟 Token 剩余时间≤6 小时(可修改 Redis 中
11.3 并发续期测试
- 工具:使用 JMeter 模拟 10 个线程同时调用
callThirdPartyUserApi
接口 - 观察日志:查看控制台日志,仅 1 个线程成功获取分布式锁执行续期,其他线程等待锁释放后直接使用新 Token
- 预期结果:第三方续期接口仅被调用 1 次,无重复续期请求,Redis 中 Token 仅更新 1 次
12. 注意事项与最佳实践
12.1 Token 安全防护
- 传输安全:第三方接口通信必须使用 HTTPS,避免 Token 在传输过程中被窃取
- 存储安全:
- Redis:开启密码认证、禁止公网访问,敏感场景可加密存储 Token
- MySQL:
access_token
字段可加密存储(如 AES 加密),避免明文泄露
- 权限控制:限制 Token 的访问范围(如第三方接口按角色分配权限),避免 Token 被盗用后造成大面积损失
12.2 并发控制优化
- 分布式锁参数调整:
- 锁等待时间:根据业务接口响应时间设置(如 10-30 秒),避免过长阻塞请求
- 锁过期时间:需大于续期接口最大响应时间(如 30-60 秒),避免死锁
- 锁自动续期:若续期操作耗时较长(如第三方接口响应慢),可在
RedisDistributedLock
中开启renewLock
定时续期功能
12.3 降级与容错设计
- Feign 降级:所有 Feign 客户端必须配置
fallbackFactory
,避免第三方接口不可用时本地服务雪崩 - Token 失效降级:若 Token 续期失败且无法登录,可返回默认降级结果或触发告警,避免业务完全中断
- 重试机制:Feign 配置重试(如
Retryer.Default
),应对网络抖动导致的临时调用失败
12.4 监控与告警
- 关键指标监控:
- Token 状态:Redis 中 Token 的剩余有效期、MySQL 备份是否同步
- 续期统计:续期成功 / 失败次数、平均续期耗时
- 接口调用:Feign 接口调用成功率、响应时间
- 告警触发条件:
- 续期失败次数≥3 次
- Token 剩余有效期≤1 小时(紧急续期)
- 第三方接口调用失败率≥50%
12.5 配置动态化
- 生产环境建议:使用 Nacos/Apollo 配置中心管理以下参数,避免重启服务:
- 第三方接口地址、clientId、clientSecret
- Token 总有效期、续期阈值(如 1/4 可改为 1/5)
- Redis、MySQL 连接信息
- 排除 URL 列表、Token 请求头名称
13. 常见问题及解决方案
问题现象 | 可能原因 | 解决方案 |
---|---|---|
Feign 调用报 401 Unauthorized | 1. 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 未备份到 MySQL | 1. Redis 开启 RDB+AOF 持久化2. 确保TokenStorageService 中getCurrentToken 方法优先从 MySQL 同步 Token |
第三方续期接口返回 “Token 已过期” | 1. 续期触发过晚(剩余时间≤0)2. 分布式锁等待时间过长 | 1. 调小续期阈值(如从 1/4 改为 1/3)2. 优化锁参数,减少锁等待时间3. 增加 Token 过期前预检查(如定时任务提前续期) |
MyBatis-Plus 插入数据时createTime 未自动填充 | 1. 实体类字段未加@TableField(fill = FieldFill.INSERT) 2. 未配置MetaObjectHandler | 1. 检查TokenEntity 中createTime 字段注解2. 确保MyBatisPlusConfig 中注册metaObjectHandler Bean |
14. 总结与优化方向
14.1 方案核心亮点
- 双存储备份:Redis 高频读取 + MySQL 持久化,确保 Token 不丢失
- 智能续期策略:1/4 有效期触发续期,平衡 “提前续期” 与 “减少请求”,避免 Token 过期
- 并发安全:分布式锁彻底解决重复续期问题,适合多实例部署场景
- 全链路自动化:Feign 拦截器无感处理 Token 续期,业务代码无需关注 Token 逻辑
- 高容错性:Feign 降级、自动重连、Token 失效自动登录,提升系统稳定性
14.2 未来优化方向
- 多客户端支持:当前方案仅支持单个
clientId
,可扩展为多客户端管理(如TokenStorageService
按clientId
分库分表) - Token 轮换机制:引入刷新 Token(Refresh Token),避免访问 Token 续期失败后需重新登录
- 定时任务兜底:增加定时任务(如每小时)检查 Token 状态,避免 Feign 请求未触发续期导致 Token 过期
- 熔断降级增强:集成 Sentinel/Hystrix,当第三方接口异常时触发熔断,返回更友好的降级结果
- 监控可视化:接入 Grafana+Prometheus,展示 Token 续期成功率、接口调用耗时等指标,支持告警可视化