初始化
This commit is contained in:
373
rdms-framework/rdms-spring-boot-starter-protection/README.md
Normal file
373
rdms-framework/rdms-spring-boot-starter-protection/README.md
Normal file
@@ -0,0 +1,373 @@
|
||||
# rdms-spring-boot-starter-protection
|
||||
|
||||
## 模块定位
|
||||
`rdms-spring-boot-starter-protection` 用于提供一组服务保护能力,减少业务代码里重复编写“防重复提交、限流、分布式锁、接口签名校验”这类横切逻辑。
|
||||
|
||||
当前模块实际包含四块能力:
|
||||
|
||||
1. 幂等:防止同一请求在短时间内被重复执行,例如重复提交、重复点击
|
||||
2. 分布式锁:在并发场景下保证同一业务动作同一时间只有一个线程或节点执行
|
||||
3. 限流:限制单位时间内的访问次数,避免接口被刷爆或高频调用
|
||||
4. HTTP API 签名:校验调用方身份、请求时效和随机数,防止伪造请求和重放攻击
|
||||
|
||||
它的目标不是提供复杂的治理平台,而是把常见保护能力做成可声明、可复用、可分布式生效的基础设施。
|
||||
|
||||
## 设计思路
|
||||
|
||||
## 1. 用注解声明保护规则,用 AOP 统一执行
|
||||
模块里的幂等、限流、签名校验都不是要求业务手写 Redis 判断逻辑,而是通过注解声明规则,再由 AOP 统一拦截执行:
|
||||
|
||||
1. `@Idempotent`
|
||||
2. `@RateLimiter`
|
||||
3. `@ApiSignature`
|
||||
|
||||
这样业务代码只负责说明“这个方法需要什么保护”,具体的 Redis Key 生成、重复请求判断、限流判断、签名验证由框架层统一处理。
|
||||
|
||||
## 2. 把状态统一放到 Redis,面向分布式部署
|
||||
这个模块的保护能力不是单机内存级别,而是默认面向多实例部署场景:
|
||||
|
||||
1. 幂等使用 `StringRedisTemplate`
|
||||
2. API 签名防重放使用 `StringRedisTemplate`
|
||||
3. 限流使用 `Redisson` 的 `RRateLimiter`
|
||||
4. 分布式锁接入 `lock4j-redisson-spring-boot-starter`
|
||||
|
||||
这意味着它的核心价值不是“本机防抖”,而是“多个节点共享保护状态”。
|
||||
|
||||
## 3. 单一能力单独建模,避免职责混杂
|
||||
从实现上看,模块有一个比较明确的边界划分:
|
||||
|
||||
1. 幂等用于“同一请求短时间内只允许执行一次”
|
||||
2. 分布式锁用于“并发竞争下只允许一个线程/节点进入”
|
||||
3. 限流用于“单位时间窗口内限制请求次数”
|
||||
4. API 签名用于“校验调用方身份、请求时效和防重放”
|
||||
|
||||
例如幂等组件没有扩展成“成功后立即删 Key”的锁语义,而是把这类能力交给 Lock4j 处理。
|
||||
|
||||
区分这两类能力时,可以抓住一个核心点:
|
||||
|
||||
1. 幂等控制的是“短时间内不要重复执行同一类请求”
|
||||
2. 分布式锁控制的是“同一时间谁有资格进入临界区执行”
|
||||
|
||||
例如:
|
||||
|
||||
1. 用户连续点击两次“提交订单”,更适合用幂等
|
||||
2. 两个线程同时刷新同一份缓存,只允许一个线程进入执行,更适合用分布式锁
|
||||
|
||||
一个常见误区是把长耗时任务也交给幂等处理。假设幂等窗口配置为 3 秒,但某个任务实际执行了 20 秒:
|
||||
|
||||
1. 第一个请求在第 0 秒进入
|
||||
2. 幂等 Key 在第 3 秒过期
|
||||
3. 第二个请求在第 5 秒再次进入
|
||||
4. 此时第一个任务还没执行完,但第二个请求已经有机会再次执行
|
||||
|
||||
所以幂等更偏“防重复请求”,而不是“在整个执行期间控制线程执行权”。如果真正关心的是执行过程中的互斥,应优先使用分布式锁。
|
||||
|
||||
## 4. 用 KeyResolver 抽象不同粒度的保护范围
|
||||
幂等和限流都没有把 Key 生成规则写死,而是抽成了 `KeyResolver`:
|
||||
|
||||
1. 默认级别:方法名 + 参数
|
||||
2. 用户级别:方法名 + 参数 + 当前用户
|
||||
3. IP 级别:方法名 + 参数 + 客户端 IP
|
||||
4. 节点级别:方法名 + 参数 + 当前服务节点
|
||||
5. 表达式级别:通过 SpEL 自定义 Key
|
||||
|
||||
这样模块能在“全局、按用户、按 IP、按节点、按业务字段”之间切换,而不是只能支持一种固定粒度。
|
||||
|
||||
## 5. 自动装配按功能块拆分
|
||||
自动配置入口见:
|
||||
`META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports`
|
||||
|
||||
当前注册了四个自动配置类:
|
||||
|
||||
1. `RdmsIdempotentConfiguration`
|
||||
2. `RdmsLock4jConfiguration`
|
||||
3. `RdmsRateLimiterConfiguration`
|
||||
4. `RdmsApiSignatureAutoConfiguration`
|
||||
|
||||
这说明模块是按能力块拆分装配的,而不是堆在一个总配置类里。
|
||||
|
||||
## 功能说明
|
||||
|
||||
## 1. 幂等
|
||||
核心类:
|
||||
|
||||
1. `Idempotent`
|
||||
2. `IdempotentAspect`
|
||||
3. `IdempotentRedisDAO`
|
||||
|
||||
实现方式:
|
||||
|
||||
1. AOP 拦截带 `@Idempotent` 的方法
|
||||
2. 通过 `IdempotentKeyResolver` 解析 Redis Key
|
||||
3. 调用 `SETNX + EXPIRE` 语义的 `setIfAbsent`
|
||||
4. 锁定失败时抛出“重复请求”异常
|
||||
5. 业务异常时可按配置删除 Key,允许后续重试
|
||||
|
||||
适合场景:
|
||||
|
||||
1. 防止用户双击按钮
|
||||
2. 防止表单重复提交
|
||||
3. 防止短时间内重复触发同一个业务动作
|
||||
|
||||
典型案例:
|
||||
|
||||
1. 创建工单时,前端连续点击两次“提交”,只允许创建一次
|
||||
2. 导入任务提交后,用户刷新页面再次点击“开始导入”,短时间内不允许重复发起
|
||||
3. 审批流提交节点时,浏览器因为网络抖动自动重发请求,后端只执行一次
|
||||
|
||||
边界说明:
|
||||
|
||||
1. 这里的幂等更偏“短时间窗口防重复请求”,不适合作为支付、退款这类长期业务幂等的唯一方案
|
||||
2. 对于支付回调、订单状态推进这类场景,更可靠的做法仍然是基于业务唯一号和持久化状态做幂等判断,必要时再配合分布式锁
|
||||
|
||||
可选 KeyResolver:
|
||||
|
||||
1. `DefaultIdempotentKeyResolver`
|
||||
2. `UserIdempotentKeyResolver`
|
||||
3. `ExpressionIdempotentKeyResolver`
|
||||
|
||||
示例:
|
||||
|
||||
```java
|
||||
@Idempotent(timeout = 3, message = "请勿重复提交")
|
||||
public Long createOrder(OrderCreateReqVO reqVO) {
|
||||
return orderService.createOrder(reqVO);
|
||||
}
|
||||
```
|
||||
|
||||
如果需要按某个参数做幂等,可以使用表达式:
|
||||
|
||||
```java
|
||||
@Idempotent(
|
||||
keyResolver = ExpressionIdempotentKeyResolver.class,
|
||||
keyArg = "#reqVO.no",
|
||||
timeout = 10
|
||||
)
|
||||
public void submit(OrderReqVO reqVO) {
|
||||
}
|
||||
```
|
||||
|
||||
如果接口已经完成登录鉴权,也可以按“当前登录用户 + 接口参数”做幂等:
|
||||
|
||||
```java
|
||||
@Idempotent(
|
||||
keyResolver = UserIdempotentKeyResolver.class,
|
||||
timeout = 3,
|
||||
message = "请勿重复提交"
|
||||
)
|
||||
public CommonResult<Long> createOrder(OrderCreateReqVO reqVO) {
|
||||
return success(orderService.createOrder(reqVO));
|
||||
}
|
||||
```
|
||||
|
||||
这种方式不要求把 `userId` 显式放在请求参数里,而是从当前登录上下文中获取用户信息,适合“同一个用户短时间内不能重复提交同一类请求”的场景。
|
||||
|
||||
## 2. 限流
|
||||
核心类:
|
||||
|
||||
1. `RateLimiter`
|
||||
2. `RateLimiterAspect`
|
||||
3. `RateLimiterRedisDAO`
|
||||
|
||||
实现方式:
|
||||
|
||||
1. AOP 在方法执行前拦截
|
||||
2. 通过 `RateLimiterKeyResolver` 解析限流 Key
|
||||
3. 使用 Redisson 的 `RRateLimiter` 设置速率
|
||||
4. 超过限制时抛出“请求过于频繁”异常
|
||||
|
||||
适合场景:
|
||||
|
||||
1. 接口防刷
|
||||
2. 高频查询保护
|
||||
3. 短时间内限制短信、验证码、导出等高成本操作
|
||||
4. 对开放接口或公网接口做基础流量保护
|
||||
|
||||
典型案例:
|
||||
|
||||
1. 短信验证码接口限制“1 分钟内最多发送 1 次”
|
||||
2. 导出 Excel 接口限制“10 分钟内最多导出 3 次”
|
||||
3. 登录接口按 IP 限制访问频率,防止暴力尝试
|
||||
4. 某个开放查询接口按调用方或用户维度限制 QPS,避免被刷爆
|
||||
|
||||
可选 KeyResolver:
|
||||
|
||||
1. `DefaultRateLimiterKeyResolver`
|
||||
2. `UserRateLimiterKeyResolver`
|
||||
3. `ClientIpRateLimiterKeyResolver`
|
||||
4. `ServerNodeRateLimiterKeyResolver`
|
||||
5. `ExpressionRateLimiterKeyResolver`
|
||||
|
||||
示例:
|
||||
|
||||
```java
|
||||
@RateLimiter(count = 5, time = 1, timeUnit = TimeUnit.MINUTES, message = "请求过于频繁")
|
||||
public CommonResult<Boolean> sendSmsCode(String mobile) {
|
||||
return success(true);
|
||||
}
|
||||
```
|
||||
|
||||
如果希望按用户限流:
|
||||
|
||||
```java
|
||||
@RateLimiter(
|
||||
count = 10,
|
||||
time = 1,
|
||||
timeUnit = TimeUnit.MINUTES,
|
||||
keyResolver = UserRateLimiterKeyResolver.class
|
||||
)
|
||||
public PageResult<OrderRespVO> pageMyOrders(OrderPageReqVO reqVO) {
|
||||
return orderService.pageMyOrders(reqVO);
|
||||
}
|
||||
```
|
||||
|
||||
## 3. HTTP API 签名
|
||||
核心类:
|
||||
|
||||
1. `ApiSignature`
|
||||
2. `ApiSignatureAspect`
|
||||
3. `ApiSignatureRedisDAO`
|
||||
|
||||
实现方式:
|
||||
|
||||
1. 从请求头中读取 `appId`、`timestamp`、`nonce`、`sign`
|
||||
2. 校验时间戳是否过期
|
||||
3. 校验 `nonce` 是否已使用,防止重放
|
||||
4. 根据 `appId` 从 Redis 中获取 `appSecret`
|
||||
5. 按“请求参数 + 请求体 + 请求头 + 密钥”构建签名字符串
|
||||
6. 使用 `SHA-256` 计算服务端签名并比对
|
||||
|
||||
适合场景:
|
||||
|
||||
1. 开放接口
|
||||
2. 系统间调用
|
||||
3. 对请求来源和重放攻击有要求的 HTTP 接口
|
||||
|
||||
典型案例:
|
||||
|
||||
1. 第三方系统调用内部开放 API 时,使用 `appId + appSecret` 做签名校验
|
||||
2. 支付平台、供应商平台、合作方系统调用回调接口时,先校验签名再进入业务处理
|
||||
3. 对外提供的 B2B 接口要求请求在 60 秒内有效,并且 `nonce` 只能使用一次
|
||||
|
||||
示例:
|
||||
|
||||
```java
|
||||
@ApiSignature(timeout = 60)
|
||||
public CommonResult<String> callback() {
|
||||
return success("ok");
|
||||
}
|
||||
```
|
||||
|
||||
当前切面是按 `@annotation(signature)` 拦截的,因此实操上应优先加在方法上。
|
||||
|
||||
## 4. 分布式锁
|
||||
核心类:
|
||||
|
||||
1. `RdmsLock4jConfiguration`
|
||||
2. `DefaultLockFailureStrategy`
|
||||
|
||||
这部分没有重复实现分布式锁,而是接入 Lock4j,并补了一层默认失败策略:
|
||||
|
||||
1. 如果类路径存在 `Lock4j`,则启用配置
|
||||
2. 在获取锁失败时,统一抛出项目内的 `ServiceException`
|
||||
3. 保持锁失败时的错误处理风格一致
|
||||
|
||||
适合场景:
|
||||
|
||||
1. 防止同一业务动作并发执行
|
||||
2. 串行化关键资源操作
|
||||
3. 抢占式处理任务
|
||||
|
||||
典型案例:
|
||||
|
||||
1. 同一个用户同时触发两次“刷新缓存”,只允许一个线程进入
|
||||
2. 定时任务集群部署时,同一时间只允许一个节点执行清理任务
|
||||
3. 库存扣减、状态迁移、批量结算这类关键动作,在并发场景下需要串行处理
|
||||
|
||||
示例:
|
||||
|
||||
```java
|
||||
@Lock4j(keys = "#userId", expire = 30000, acquireTimeout = 1000)
|
||||
public void refreshUserCache(Long userId) {
|
||||
}
|
||||
```
|
||||
|
||||
## 自动装配链路
|
||||
|
||||
## 1. Spring Boot 自动配置入口
|
||||
`META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports`
|
||||
|
||||
1. `com.njcn.rdms.framework.idempotent.config.RdmsIdempotentConfiguration`
|
||||
2. `com.njcn.rdms.framework.lock4j.config.RdmsLock4jConfiguration`
|
||||
3. `com.njcn.rdms.framework.ratelimiter.config.RdmsRateLimiterConfiguration`
|
||||
4. `com.njcn.rdms.framework.signature.config.RdmsApiSignatureAutoConfiguration`
|
||||
|
||||
## 2. 各配置类负责的事情
|
||||
|
||||
| 配置类 | 作用 |
|
||||
| --- | --- |
|
||||
| `RdmsIdempotentConfiguration` | 注册幂等切面、幂等 Redis DAO、幂等 KeyResolver |
|
||||
| `RdmsLock4jConfiguration` | 在 Lock4j 存在时注册默认加锁失败策略 |
|
||||
| `RdmsRateLimiterConfiguration` | 注册限流切面、限流 Redis DAO、限流 KeyResolver |
|
||||
| `RdmsApiSignatureAutoConfiguration` | 注册 API 签名切面、签名 Redis DAO |
|
||||
|
||||
## 如何使用
|
||||
|
||||
## 1. 引入依赖
|
||||
业务模块通常直接依赖:
|
||||
|
||||
```xml
|
||||
<dependency>
|
||||
<groupId>com.njcn</groupId>
|
||||
<artifactId>rdms-spring-boot-starter-protection</artifactId>
|
||||
</dependency>
|
||||
```
|
||||
|
||||
## 2. 基础前提
|
||||
这个模块依赖 Redis,因此至少需要可用的 Redis 配置。
|
||||
|
||||
其中:
|
||||
|
||||
1. 幂等依赖 `StringRedisTemplate`
|
||||
2. API 签名依赖 `StringRedisTemplate`
|
||||
3. 限流依赖 `RedissonClient`
|
||||
4. 分布式锁依赖 Lock4j + Redisson
|
||||
|
||||
## 3. 选择合适的保护手段
|
||||
|
||||
1. 防重复提交,优先使用 `@Idempotent`
|
||||
2. 控制访问频率,优先使用 `@RateLimiter`
|
||||
3. 控制并发进入,优先使用 Lock4j
|
||||
4. 保护开放接口,优先使用 `@ApiSignature`
|
||||
|
||||
不要把它们混成一类能力:
|
||||
|
||||
1. 幂等不等于分布式锁
|
||||
2. 限流不等于幂等
|
||||
3. API 签名不等于权限校验
|
||||
|
||||
## 4. KeyResolver 的选择建议
|
||||
|
||||
1. 默认对全局请求做保护,使用默认 Resolver
|
||||
2. 需要按登录用户隔离保护,使用 User Resolver
|
||||
3. 需要按 IP 控制访问,使用 ClientIp Resolver
|
||||
4. 需要按业务字段控制粒度,使用 Expression Resolver
|
||||
|
||||
## 注意事项
|
||||
|
||||
1. 幂等的 `timeout` 只是保护窗口,不是业务执行超时时间。如果方法执行时间长于这个窗口,后续请求仍可能进入。
|
||||
2. 幂等在异常时默认删除 Key,这是为了允许业务失败后重新提交;如果追求的是“执行期间只允许一个线程进入”,更适合使用分布式锁。
|
||||
3. 限流底层使用 Redisson 的 `RRateLimiter`,运行环境需要有 `RedissonClient`。
|
||||
4. API 签名依赖 Redis 中预先存在 `appId -> appSecret` 的映射,否则签名校验无法通过。
|
||||
5. API 签名当前实操上应加在方法上,避免把类级注解误认为一定会生效。
|
||||
6. 这个模块的保护能力都是方法级横切逻辑,适合放在 Controller 或 Service 的明确边界上使用,不适合到处滥加。
|
||||
|
||||
## 总结
|
||||
这个模块的核心价值,是把服务保护相关的高频横切问题统一沉到框架层:
|
||||
|
||||
1. 如何防止重复提交
|
||||
2. 如何限制单位时间的访问频率
|
||||
3. 如何在分布式环境下串行化关键操作
|
||||
4. 如何校验开放接口的请求签名和防重放
|
||||
|
||||
如果把它理解成一个“请求保护与并发保护能力集合”的 starter,这个模块的设计会比较清晰。
|
||||
47
rdms-framework/rdms-spring-boot-starter-protection/pom.xml
Normal file
47
rdms-framework/rdms-spring-boot-starter-protection/pom.xml
Normal file
@@ -0,0 +1,47 @@
|
||||
<?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 http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
<parent>
|
||||
<groupId>com.njcn</groupId>
|
||||
<artifactId>rdms-framework</artifactId>
|
||||
<version>${revision}</version>
|
||||
</parent>
|
||||
|
||||
<artifactId>rdms-spring-boot-starter-protection</artifactId>
|
||||
<packaging>jar</packaging>
|
||||
|
||||
<name>${project.artifactId}</name>
|
||||
<description>服务保证,提供分布式锁、幂等、限流、熔断等等功能</description>
|
||||
|
||||
|
||||
<dependencies>
|
||||
<!-- Web 相关 -->
|
||||
<dependency>
|
||||
<groupId>com.njcn</groupId>
|
||||
<artifactId>rdms-spring-boot-starter-web</artifactId>
|
||||
<scope>provided</scope> <!-- 设置为 provided,只有限流、幂等使用到 -->
|
||||
</dependency>
|
||||
|
||||
<!-- DB 相关 -->
|
||||
<dependency>
|
||||
<groupId>com.njcn</groupId>
|
||||
<artifactId>rdms-spring-boot-starter-redis</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- 服务保障相关 -->
|
||||
<dependency>
|
||||
<groupId>com.baomidou</groupId>
|
||||
<artifactId>lock4j-redisson-spring-boot-starter</artifactId>
|
||||
<optional>true</optional>
|
||||
</dependency>
|
||||
|
||||
<!-- Test 测试相关 -->
|
||||
<dependency>
|
||||
<groupId>com.njcn</groupId>
|
||||
<artifactId>rdms-spring-boot-starter-test</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
</project>
|
||||
@@ -0,0 +1,46 @@
|
||||
package com.njcn.rdms.framework.idempotent.config;
|
||||
|
||||
import com.njcn.rdms.framework.idempotent.core.aop.IdempotentAspect;
|
||||
import com.njcn.rdms.framework.idempotent.core.keyresolver.IdempotentKeyResolver;
|
||||
import com.njcn.rdms.framework.idempotent.core.keyresolver.impl.DefaultIdempotentKeyResolver;
|
||||
import com.njcn.rdms.framework.idempotent.core.keyresolver.impl.ExpressionIdempotentKeyResolver;
|
||||
import com.njcn.rdms.framework.idempotent.core.keyresolver.impl.UserIdempotentKeyResolver;
|
||||
import com.njcn.rdms.framework.idempotent.core.redis.IdempotentRedisDAO;
|
||||
import com.njcn.rdms.framework.redis.config.RdmsRedisAutoConfiguration;
|
||||
import org.springframework.boot.autoconfigure.AutoConfiguration;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.data.redis.core.StringRedisTemplate;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@AutoConfiguration(after = RdmsRedisAutoConfiguration.class)
|
||||
public class RdmsIdempotentConfiguration {
|
||||
|
||||
@Bean
|
||||
public IdempotentAspect idempotentAspect(List<IdempotentKeyResolver> keyResolvers, IdempotentRedisDAO idempotentRedisDAO) {
|
||||
return new IdempotentAspect(keyResolvers, idempotentRedisDAO);
|
||||
}
|
||||
|
||||
@Bean
|
||||
public IdempotentRedisDAO idempotentRedisDAO(StringRedisTemplate stringRedisTemplate) {
|
||||
return new IdempotentRedisDAO(stringRedisTemplate);
|
||||
}
|
||||
|
||||
// ========== 各种 IdempotentKeyResolver Bean ==========
|
||||
|
||||
@Bean
|
||||
public DefaultIdempotentKeyResolver defaultIdempotentKeyResolver() {
|
||||
return new DefaultIdempotentKeyResolver();
|
||||
}
|
||||
|
||||
@Bean
|
||||
public UserIdempotentKeyResolver userIdempotentKeyResolver() {
|
||||
return new UserIdempotentKeyResolver();
|
||||
}
|
||||
|
||||
@Bean
|
||||
public ExpressionIdempotentKeyResolver expressionIdempotentKeyResolver() {
|
||||
return new ExpressionIdempotentKeyResolver();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package com.njcn.rdms.framework.idempotent.core.annotation;
|
||||
|
||||
import com.njcn.rdms.framework.idempotent.core.keyresolver.IdempotentKeyResolver;
|
||||
import com.njcn.rdms.framework.idempotent.core.keyresolver.impl.DefaultIdempotentKeyResolver;
|
||||
import com.njcn.rdms.framework.idempotent.core.keyresolver.impl.ExpressionIdempotentKeyResolver;
|
||||
import com.njcn.rdms.framework.idempotent.core.keyresolver.impl.UserIdempotentKeyResolver;
|
||||
|
||||
import java.lang.annotation.ElementType;
|
||||
import java.lang.annotation.Retention;
|
||||
import java.lang.annotation.RetentionPolicy;
|
||||
import java.lang.annotation.Target;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
/**
|
||||
* 幂等注解
|
||||
*
|
||||
* @author hongawen
|
||||
*/
|
||||
@Target({ElementType.METHOD})
|
||||
@Retention(RetentionPolicy.RUNTIME)
|
||||
public @interface Idempotent {
|
||||
|
||||
/**
|
||||
* 幂等的超时时间,默认为 1 秒
|
||||
*
|
||||
* 注意,如果执行时间超过它,请求还是会进来
|
||||
*/
|
||||
int timeout() default 1;
|
||||
/**
|
||||
* 时间单位,默认为 SECONDS 秒
|
||||
*/
|
||||
TimeUnit timeUnit() default TimeUnit.SECONDS;
|
||||
|
||||
/**
|
||||
* 提示信息,正在执行中的提示
|
||||
*/
|
||||
String message() default "重复请求,请稍后重试";
|
||||
|
||||
/**
|
||||
* 使用的 Key 解析器
|
||||
*
|
||||
* @see DefaultIdempotentKeyResolver 全局级别
|
||||
* @see UserIdempotentKeyResolver 用户级别
|
||||
* @see ExpressionIdempotentKeyResolver 自定义表达式,通过 {@link #keyArg()} 计算
|
||||
*/
|
||||
Class<? extends IdempotentKeyResolver> keyResolver() default DefaultIdempotentKeyResolver.class;
|
||||
/**
|
||||
* 使用的 Key 参数
|
||||
*/
|
||||
String keyArg() default "";
|
||||
|
||||
/**
|
||||
* 删除 Key,当发生异常时候
|
||||
*
|
||||
* 问题:为什么发生异常时,需要删除 Key 呢?
|
||||
* 回答:发生异常时,说明业务发生错误,此时需要删除 Key,避免下次请求无法正常执行。
|
||||
*
|
||||
* 问题:为什么不搞 deleteWhenSuccess 执行成功时,需要删除 Key 呢?
|
||||
* 回答:这种情况下,本质上是分布式锁,推荐使用 @Lock4j 注解
|
||||
*/
|
||||
boolean deleteKeyWhenException() default true;
|
||||
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package com.njcn.rdms.framework.idempotent.core.aop;
|
||||
|
||||
import com.njcn.rdms.framework.common.exception.ServiceException;
|
||||
import com.njcn.rdms.framework.common.exception.enums.GlobalErrorCodeConstants;
|
||||
import com.njcn.rdms.framework.common.util.collection.CollectionUtils;
|
||||
import com.njcn.rdms.framework.idempotent.core.annotation.Idempotent;
|
||||
import com.njcn.rdms.framework.idempotent.core.keyresolver.IdempotentKeyResolver;
|
||||
import com.njcn.rdms.framework.idempotent.core.redis.IdempotentRedisDAO;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.aspectj.lang.ProceedingJoinPoint;
|
||||
import org.aspectj.lang.annotation.Around;
|
||||
import org.aspectj.lang.annotation.Aspect;
|
||||
import org.springframework.util.Assert;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 拦截声明了 {@link Idempotent} 注解的方法,实现幂等操作
|
||||
*
|
||||
* @author hongawen
|
||||
*/
|
||||
@Aspect
|
||||
@Slf4j
|
||||
public class IdempotentAspect {
|
||||
|
||||
/**
|
||||
* IdempotentKeyResolver 集合
|
||||
*/
|
||||
private final Map<Class<? extends IdempotentKeyResolver>, IdempotentKeyResolver> keyResolvers;
|
||||
|
||||
private final IdempotentRedisDAO idempotentRedisDAO;
|
||||
|
||||
public IdempotentAspect(List<IdempotentKeyResolver> keyResolvers, IdempotentRedisDAO idempotentRedisDAO) {
|
||||
this.keyResolvers = CollectionUtils.convertMap(keyResolvers, IdempotentKeyResolver::getClass);
|
||||
this.idempotentRedisDAO = idempotentRedisDAO;
|
||||
}
|
||||
|
||||
@Around(value = "@annotation(idempotent)")
|
||||
public Object aroundPointCut(ProceedingJoinPoint joinPoint, Idempotent idempotent) throws Throwable {
|
||||
// 获得 IdempotentKeyResolver
|
||||
IdempotentKeyResolver keyResolver = keyResolvers.get(idempotent.keyResolver());
|
||||
Assert.notNull(keyResolver, "找不到对应的 IdempotentKeyResolver");
|
||||
// 解析 Key
|
||||
String key = keyResolver.resolver(joinPoint, idempotent);
|
||||
|
||||
// 1. 锁定 Key
|
||||
boolean success = idempotentRedisDAO.setIfAbsent(key, idempotent.timeout(), idempotent.timeUnit());
|
||||
// 锁定失败,抛出异常
|
||||
if (!success) {
|
||||
log.info("[aroundPointCut][方法({}) 参数({}) 存在重复请求]", joinPoint.getSignature().toString(), joinPoint.getArgs());
|
||||
throw new ServiceException(GlobalErrorCodeConstants.REPEATED_REQUESTS.getCode(), idempotent.message());
|
||||
}
|
||||
|
||||
// 2. 执行逻辑
|
||||
try {
|
||||
return joinPoint.proceed();
|
||||
} catch (Throwable throwable) {
|
||||
// 3. 异常时,删除 Key
|
||||
// 参考美团 GTIS 思路:https://tech.meituan.com/2016/09/29/distributed-system-mutually-exclusive-idempotence-cerberus-gtis.html
|
||||
if (idempotent.deleteKeyWhenException()) {
|
||||
idempotentRedisDAO.delete(key);
|
||||
}
|
||||
throw throwable;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package com.njcn.rdms.framework.idempotent.core.keyresolver;
|
||||
|
||||
import com.njcn.rdms.framework.idempotent.core.annotation.Idempotent;
|
||||
import org.aspectj.lang.JoinPoint;
|
||||
|
||||
/**
|
||||
* 幂等 Key 解析器接口
|
||||
*
|
||||
* @author hongawen
|
||||
*/
|
||||
public interface IdempotentKeyResolver {
|
||||
|
||||
/**
|
||||
* 解析一个 Key
|
||||
*
|
||||
* @param idempotent 幂等注解
|
||||
* @param joinPoint AOP 切面
|
||||
* @return Key
|
||||
*/
|
||||
String resolver(JoinPoint joinPoint, Idempotent idempotent);
|
||||
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package com.njcn.rdms.framework.idempotent.core.keyresolver.impl;
|
||||
|
||||
import cn.hutool.crypto.SecureUtil;
|
||||
import com.njcn.rdms.framework.common.util.string.StrUtils;
|
||||
import com.njcn.rdms.framework.idempotent.core.annotation.Idempotent;
|
||||
import com.njcn.rdms.framework.idempotent.core.keyresolver.IdempotentKeyResolver;
|
||||
import org.aspectj.lang.JoinPoint;
|
||||
|
||||
/**
|
||||
* 默认(全局级别)幂等 Key 解析器,使用方法名 + 方法参数,组装成一个 Key
|
||||
*
|
||||
* 为了避免 Key 过长,使用 MD5 进行“压缩”
|
||||
*
|
||||
* @author hongawen
|
||||
*/
|
||||
public class DefaultIdempotentKeyResolver implements IdempotentKeyResolver {
|
||||
|
||||
@Override
|
||||
public String resolver(JoinPoint joinPoint, Idempotent idempotent) {
|
||||
String methodName = joinPoint.getSignature().toString();
|
||||
String argsStr = StrUtils.joinMethodArgs(joinPoint);
|
||||
return SecureUtil.md5(methodName + argsStr);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package com.njcn.rdms.framework.idempotent.core.keyresolver.impl;
|
||||
|
||||
import cn.hutool.core.util.ArrayUtil;
|
||||
import com.njcn.rdms.framework.idempotent.core.annotation.Idempotent;
|
||||
import com.njcn.rdms.framework.idempotent.core.keyresolver.IdempotentKeyResolver;
|
||||
import org.aspectj.lang.JoinPoint;
|
||||
import org.aspectj.lang.reflect.MethodSignature;
|
||||
import org.springframework.core.DefaultParameterNameDiscoverer;
|
||||
import org.springframework.core.ParameterNameDiscoverer;
|
||||
import org.springframework.expression.Expression;
|
||||
import org.springframework.expression.ExpressionParser;
|
||||
import org.springframework.expression.spel.standard.SpelExpressionParser;
|
||||
import org.springframework.expression.spel.support.StandardEvaluationContext;
|
||||
|
||||
import java.lang.reflect.Method;
|
||||
|
||||
/**
|
||||
* 基于 Spring EL 表达式,
|
||||
*
|
||||
* @author hongawen
|
||||
*/
|
||||
public class ExpressionIdempotentKeyResolver implements IdempotentKeyResolver {
|
||||
|
||||
private final ParameterNameDiscoverer parameterNameDiscoverer = new DefaultParameterNameDiscoverer();
|
||||
|
||||
private final ExpressionParser expressionParser = new SpelExpressionParser();
|
||||
|
||||
@Override
|
||||
public String resolver(JoinPoint joinPoint, Idempotent idempotent) {
|
||||
// 获得被拦截方法参数名列表
|
||||
Method method = getMethod(joinPoint);
|
||||
Object[] args = joinPoint.getArgs();
|
||||
String[] parameterNames = this.parameterNameDiscoverer.getParameterNames(method);
|
||||
// 准备 Spring EL 表达式解析的上下文
|
||||
StandardEvaluationContext evaluationContext = new StandardEvaluationContext();
|
||||
if (ArrayUtil.isNotEmpty(parameterNames)) {
|
||||
for (int i = 0; i < parameterNames.length; i++) {
|
||||
evaluationContext.setVariable(parameterNames[i], args[i]);
|
||||
}
|
||||
}
|
||||
|
||||
// 解析参数
|
||||
Expression expression = expressionParser.parseExpression(idempotent.keyArg());
|
||||
return expression.getValue(evaluationContext, String.class);
|
||||
}
|
||||
|
||||
private static Method getMethod(JoinPoint point) {
|
||||
// 处理,声明在类上的情况
|
||||
MethodSignature signature = (MethodSignature) point.getSignature();
|
||||
Method method = signature.getMethod();
|
||||
if (!method.getDeclaringClass().isInterface()) {
|
||||
return method;
|
||||
}
|
||||
|
||||
// 处理,声明在接口上的情况
|
||||
try {
|
||||
return point.getTarget().getClass().getDeclaredMethod(
|
||||
point.getSignature().getName(), method.getParameterTypes());
|
||||
} catch (NoSuchMethodException e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package com.njcn.rdms.framework.idempotent.core.keyresolver.impl;
|
||||
|
||||
import cn.hutool.crypto.SecureUtil;
|
||||
import com.njcn.rdms.framework.common.util.string.StrUtils;
|
||||
import com.njcn.rdms.framework.idempotent.core.annotation.Idempotent;
|
||||
import com.njcn.rdms.framework.idempotent.core.keyresolver.IdempotentKeyResolver;
|
||||
import com.njcn.rdms.framework.web.core.util.WebFrameworkUtils;
|
||||
import org.aspectj.lang.JoinPoint;
|
||||
|
||||
/**
|
||||
* 用户级别的幂等 Key 解析器,使用方法名 + 方法参数 + userId + userType,组装成一个 Key
|
||||
*
|
||||
* 为了避免 Key 过长,使用 MD5 进行“压缩”
|
||||
*
|
||||
* @author hongawen
|
||||
*/
|
||||
public class UserIdempotentKeyResolver implements IdempotentKeyResolver {
|
||||
|
||||
@Override
|
||||
public String resolver(JoinPoint joinPoint, Idempotent idempotent) {
|
||||
String methodName = joinPoint.getSignature().toString();
|
||||
String argsStr = StrUtils.joinMethodArgs(joinPoint);
|
||||
Long userId = WebFrameworkUtils.getLoginUserId();
|
||||
Integer userType = WebFrameworkUtils.getLoginUserType();
|
||||
return SecureUtil.md5(methodName + argsStr + userId + userType);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package com.njcn.rdms.framework.idempotent.core.redis;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import org.springframework.data.redis.core.StringRedisTemplate;
|
||||
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
/**
|
||||
* 幂等 Redis DAO
|
||||
*
|
||||
* @author hongawen
|
||||
*/
|
||||
@AllArgsConstructor
|
||||
public class IdempotentRedisDAO {
|
||||
|
||||
/**
|
||||
* 幂等操作
|
||||
*
|
||||
* KEY 格式:idempotent:%s // 参数为 uuid
|
||||
* VALUE 格式:String
|
||||
* 过期时间:不固定
|
||||
*/
|
||||
private static final String IDEMPOTENT = "idempotent:%s";
|
||||
|
||||
private final StringRedisTemplate redisTemplate;
|
||||
|
||||
public Boolean setIfAbsent(String key, long timeout, TimeUnit timeUnit) {
|
||||
String redisKey = formatKey(key);
|
||||
return redisTemplate.opsForValue().setIfAbsent(redisKey, "", timeout, timeUnit);
|
||||
}
|
||||
|
||||
public void delete(String key) {
|
||||
String redisKey = formatKey(key);
|
||||
redisTemplate.delete(redisKey);
|
||||
}
|
||||
|
||||
private static String formatKey(String key) {
|
||||
return String.format(IDEMPOTENT, key);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* 幂等组件,参考 https://github.com/it4alla/idempotent 项目实现
|
||||
* 实现原理是,相同参数的方法,一段时间内,有且仅能执行一次。通过这样的方式,保证幂等性。
|
||||
*
|
||||
* 使用场景:例如说,用户快速的双击了某个按钮,前端没有禁用该按钮,导致发送了两次重复的请求。
|
||||
*
|
||||
* 和 it4alla/idempotent 组件的差异点,主要体现在两点:
|
||||
* 1. 我们去掉了 @Idempotent 注解的 delKey 属性。原因是,本质上 delKey 为 true 时,实现的是分布式锁的能力
|
||||
* 此时,我们偏向使用 Lock4j 组件。原则上,一个组件只提供一种单一的能力。
|
||||
* 2. 考虑到组件的通用性,我们并未像 it4alla/idempotent 组件一样使用 Redisson RMap 结构,而是直接使用 Redis 的 String 数据格式。
|
||||
*/
|
||||
package com.njcn.rdms.framework.idempotent;
|
||||
@@ -0,0 +1,18 @@
|
||||
package com.njcn.rdms.framework.lock4j.config;
|
||||
|
||||
import com.baomidou.lock.spring.boot.autoconfigure.LockAutoConfiguration;
|
||||
import com.njcn.rdms.framework.lock4j.core.DefaultLockFailureStrategy;
|
||||
import org.springframework.boot.autoconfigure.AutoConfiguration;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
|
||||
@AutoConfiguration(before = LockAutoConfiguration.class)
|
||||
@ConditionalOnClass(name = "com.baomidou.lock.annotation.Lock4j")
|
||||
public class RdmsLock4jConfiguration {
|
||||
|
||||
@Bean
|
||||
public DefaultLockFailureStrategy lockFailureStrategy() {
|
||||
return new DefaultLockFailureStrategy();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package com.njcn.rdms.framework.lock4j.core;
|
||||
|
||||
import com.baomidou.lock.LockFailureStrategy;
|
||||
import com.njcn.rdms.framework.common.exception.ServiceException;
|
||||
import com.njcn.rdms.framework.common.exception.enums.GlobalErrorCodeConstants;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import java.lang.reflect.Method;
|
||||
|
||||
/**
|
||||
* 自定义获取锁失败策略,抛出 {@link ServiceException} 异常
|
||||
*/
|
||||
@Slf4j
|
||||
public class DefaultLockFailureStrategy implements LockFailureStrategy {
|
||||
|
||||
@Override
|
||||
public void onLockFailure(String key, Method method, Object[] arguments) {
|
||||
log.debug("[onLockFailure][线程:{} 获取锁失败,key:{} 获取失败:{} ]", Thread.currentThread().getName(), key, arguments);
|
||||
throw new ServiceException(GlobalErrorCodeConstants.LOCKED);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package com.njcn.rdms.framework.lock4j.core;
|
||||
|
||||
/**
|
||||
* Lock4j Redis Key 枚举类
|
||||
*
|
||||
* @author hongawen
|
||||
*/
|
||||
public interface Lock4jRedisKeyConstants {
|
||||
|
||||
/**
|
||||
* 分布式锁
|
||||
*
|
||||
* KEY 格式:lock4j:%s // 参数来自 DefaultLockKeyBuilder 类
|
||||
* VALUE 数据格式:HASH // RLock.class:Redisson 的 Lock 锁,使用 Hash 数据结构
|
||||
* 过期时间:不固定
|
||||
*/
|
||||
String LOCK4J = "lock4j:%s";
|
||||
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
/**
|
||||
* 分布式锁组件,使用 https://gitee.com/baomidou/lock4j 开源项目
|
||||
*/
|
||||
package com.njcn.rdms.framework.lock4j;
|
||||
@@ -0,0 +1,55 @@
|
||||
package com.njcn.rdms.framework.ratelimiter.config;
|
||||
|
||||
import com.njcn.rdms.framework.ratelimiter.core.aop.RateLimiterAspect;
|
||||
import com.njcn.rdms.framework.ratelimiter.core.keyresolver.RateLimiterKeyResolver;
|
||||
import com.njcn.rdms.framework.ratelimiter.core.keyresolver.impl.*;
|
||||
import com.njcn.rdms.framework.ratelimiter.core.redis.RateLimiterRedisDAO;
|
||||
import com.njcn.rdms.framework.redis.config.RdmsRedisAutoConfiguration;
|
||||
import org.redisson.api.RedissonClient;
|
||||
import org.springframework.boot.autoconfigure.AutoConfiguration;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@AutoConfiguration(after = RdmsRedisAutoConfiguration.class)
|
||||
public class RdmsRateLimiterConfiguration {
|
||||
|
||||
@Bean
|
||||
public RateLimiterAspect rateLimiterAspect(List<RateLimiterKeyResolver> keyResolvers, RateLimiterRedisDAO rateLimiterRedisDAO) {
|
||||
return new RateLimiterAspect(keyResolvers, rateLimiterRedisDAO);
|
||||
}
|
||||
|
||||
@Bean
|
||||
@SuppressWarnings("SpringJavaInjectionPointsAutowiringInspection")
|
||||
public RateLimiterRedisDAO rateLimiterRedisDAO(RedissonClient redissonClient) {
|
||||
return new RateLimiterRedisDAO(redissonClient);
|
||||
}
|
||||
|
||||
// ========== 各种 RateLimiterRedisDAO Bean ==========
|
||||
|
||||
@Bean
|
||||
public DefaultRateLimiterKeyResolver defaultRateLimiterKeyResolver() {
|
||||
return new DefaultRateLimiterKeyResolver();
|
||||
}
|
||||
|
||||
@Bean
|
||||
public UserRateLimiterKeyResolver userRateLimiterKeyResolver() {
|
||||
return new UserRateLimiterKeyResolver();
|
||||
}
|
||||
|
||||
@Bean
|
||||
public ClientIpRateLimiterKeyResolver clientIpRateLimiterKeyResolver() {
|
||||
return new ClientIpRateLimiterKeyResolver();
|
||||
}
|
||||
|
||||
@Bean
|
||||
public ServerNodeRateLimiterKeyResolver serverNodeRateLimiterKeyResolver() {
|
||||
return new ServerNodeRateLimiterKeyResolver();
|
||||
}
|
||||
|
||||
@Bean
|
||||
public ExpressionRateLimiterKeyResolver expressionRateLimiterKeyResolver() {
|
||||
return new ExpressionRateLimiterKeyResolver();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package com.njcn.rdms.framework.ratelimiter.core.annotation;
|
||||
|
||||
import com.njcn.rdms.framework.common.exception.enums.GlobalErrorCodeConstants;
|
||||
import com.njcn.rdms.framework.idempotent.core.keyresolver.impl.ExpressionIdempotentKeyResolver;
|
||||
import com.njcn.rdms.framework.ratelimiter.core.keyresolver.RateLimiterKeyResolver;
|
||||
import com.njcn.rdms.framework.ratelimiter.core.keyresolver.impl.ClientIpRateLimiterKeyResolver;
|
||||
import com.njcn.rdms.framework.ratelimiter.core.keyresolver.impl.DefaultRateLimiterKeyResolver;
|
||||
import com.njcn.rdms.framework.ratelimiter.core.keyresolver.impl.ServerNodeRateLimiterKeyResolver;
|
||||
import com.njcn.rdms.framework.ratelimiter.core.keyresolver.impl.UserRateLimiterKeyResolver;
|
||||
|
||||
import java.lang.annotation.ElementType;
|
||||
import java.lang.annotation.Retention;
|
||||
import java.lang.annotation.RetentionPolicy;
|
||||
import java.lang.annotation.Target;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
/**
|
||||
* 限流注解
|
||||
*
|
||||
* @author hongawen
|
||||
*/
|
||||
@Target({ElementType.METHOD})
|
||||
@Retention(RetentionPolicy.RUNTIME)
|
||||
public @interface RateLimiter {
|
||||
|
||||
/**
|
||||
* 限流的时间,默认为 1 秒
|
||||
*/
|
||||
int time() default 1;
|
||||
/**
|
||||
* 时间单位,默认为 SECONDS 秒
|
||||
*/
|
||||
TimeUnit timeUnit() default TimeUnit.SECONDS;
|
||||
|
||||
/**
|
||||
* 限流次数
|
||||
*/
|
||||
int count() default 100;
|
||||
|
||||
/**
|
||||
* 提示信息,请求过快的提示
|
||||
*
|
||||
* @see GlobalErrorCodeConstants#TOO_MANY_REQUESTS
|
||||
*/
|
||||
String message() default ""; // 为空时,使用 TOO_MANY_REQUESTS 错误提示
|
||||
|
||||
/**
|
||||
* 使用的 Key 解析器
|
||||
*
|
||||
* @see DefaultRateLimiterKeyResolver 全局级别
|
||||
* @see UserRateLimiterKeyResolver 用户 ID 级别
|
||||
* @see ClientIpRateLimiterKeyResolver 用户 IP 级别
|
||||
* @see ServerNodeRateLimiterKeyResolver 服务器 Node 级别
|
||||
* @see ExpressionIdempotentKeyResolver 自定义表达式,通过 {@link #keyArg()} 计算
|
||||
*/
|
||||
Class<? extends RateLimiterKeyResolver> keyResolver() default DefaultRateLimiterKeyResolver.class;
|
||||
/**
|
||||
* 使用的 Key 参数
|
||||
*/
|
||||
String keyArg() default "";
|
||||
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package com.njcn.rdms.framework.ratelimiter.core.aop;
|
||||
|
||||
import cn.hutool.core.util.StrUtil;
|
||||
import com.njcn.rdms.framework.common.exception.ServiceException;
|
||||
import com.njcn.rdms.framework.common.exception.enums.GlobalErrorCodeConstants;
|
||||
import com.njcn.rdms.framework.common.util.collection.CollectionUtils;
|
||||
import com.njcn.rdms.framework.ratelimiter.core.annotation.RateLimiter;
|
||||
import com.njcn.rdms.framework.ratelimiter.core.keyresolver.RateLimiterKeyResolver;
|
||||
import com.njcn.rdms.framework.ratelimiter.core.redis.RateLimiterRedisDAO;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.aspectj.lang.JoinPoint;
|
||||
import org.aspectj.lang.annotation.Aspect;
|
||||
import org.aspectj.lang.annotation.Before;
|
||||
import org.springframework.util.Assert;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 拦截声明了 {@link RateLimiter} 注解的方法,实现限流操作
|
||||
*
|
||||
* @author hongawen
|
||||
*/
|
||||
@Aspect
|
||||
@Slf4j
|
||||
public class RateLimiterAspect {
|
||||
|
||||
/**
|
||||
* RateLimiterKeyResolver 集合
|
||||
*/
|
||||
private final Map<Class<? extends RateLimiterKeyResolver>, RateLimiterKeyResolver> keyResolvers;
|
||||
|
||||
private final RateLimiterRedisDAO rateLimiterRedisDAO;
|
||||
|
||||
public RateLimiterAspect(List<RateLimiterKeyResolver> keyResolvers, RateLimiterRedisDAO rateLimiterRedisDAO) {
|
||||
this.keyResolvers = CollectionUtils.convertMap(keyResolvers, RateLimiterKeyResolver::getClass);
|
||||
this.rateLimiterRedisDAO = rateLimiterRedisDAO;
|
||||
}
|
||||
|
||||
@Before("@annotation(rateLimiter)")
|
||||
public void beforePointCut(JoinPoint joinPoint, RateLimiter rateLimiter) {
|
||||
// 获得 RateLimiterKeyResolver 对象
|
||||
RateLimiterKeyResolver keyResolver = keyResolvers.get(rateLimiter.keyResolver());
|
||||
Assert.notNull(keyResolver, "找不到对应的 RateLimiterKeyResolver");
|
||||
// 解析 Key
|
||||
String key = keyResolver.resolver(joinPoint, rateLimiter);
|
||||
|
||||
// 获取 1 次限流
|
||||
boolean success = rateLimiterRedisDAO.tryAcquire(key,
|
||||
rateLimiter.count(), rateLimiter.time(), rateLimiter.timeUnit());
|
||||
if (!success) {
|
||||
log.info("[beforePointCut][方法({}) 参数({}) 请求过于频繁]", joinPoint.getSignature().toString(), joinPoint.getArgs());
|
||||
String message = StrUtil.blankToDefault(rateLimiter.message(),
|
||||
GlobalErrorCodeConstants.TOO_MANY_REQUESTS.getMsg());
|
||||
throw new ServiceException(GlobalErrorCodeConstants.TOO_MANY_REQUESTS.getCode(), message);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
package com.njcn.rdms.framework.ratelimiter.core.keyresolver;
|
||||
|
||||
import com.njcn.rdms.framework.ratelimiter.core.annotation.RateLimiter;
|
||||
import org.aspectj.lang.JoinPoint;
|
||||
|
||||
/**
|
||||
* 限流 Key 解析器接口
|
||||
*
|
||||
* @author hongawen
|
||||
*/
|
||||
public interface RateLimiterKeyResolver {
|
||||
|
||||
/**
|
||||
* 解析一个 Key
|
||||
*
|
||||
* @param rateLimiter 限流注解
|
||||
* @param joinPoint AOP 切面
|
||||
* @return Key
|
||||
*/
|
||||
String resolver(JoinPoint joinPoint, RateLimiter rateLimiter);
|
||||
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package com.njcn.rdms.framework.ratelimiter.core.keyresolver.impl;
|
||||
|
||||
import cn.hutool.crypto.SecureUtil;
|
||||
import com.njcn.rdms.framework.common.util.servlet.ServletUtils;
|
||||
import com.njcn.rdms.framework.common.util.string.StrUtils;
|
||||
import com.njcn.rdms.framework.ratelimiter.core.annotation.RateLimiter;
|
||||
import com.njcn.rdms.framework.ratelimiter.core.keyresolver.RateLimiterKeyResolver;
|
||||
import org.aspectj.lang.JoinPoint;
|
||||
|
||||
/**
|
||||
* IP 级别的限流 Key 解析器,使用方法名 + 方法参数 + IP,组装成一个 Key
|
||||
*
|
||||
* 为了避免 Key 过长,使用 MD5 进行“压缩”
|
||||
*
|
||||
* @author hongawen
|
||||
*/
|
||||
public class ClientIpRateLimiterKeyResolver implements RateLimiterKeyResolver {
|
||||
|
||||
@Override
|
||||
public String resolver(JoinPoint joinPoint, RateLimiter rateLimiter) {
|
||||
String methodName = joinPoint.getSignature().toString();
|
||||
String argsStr = StrUtils.joinMethodArgs(joinPoint);
|
||||
String clientIp = ServletUtils.getClientIP();
|
||||
return SecureUtil.md5(methodName + argsStr + clientIp);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package com.njcn.rdms.framework.ratelimiter.core.keyresolver.impl;
|
||||
|
||||
import cn.hutool.crypto.SecureUtil;
|
||||
import com.njcn.rdms.framework.common.util.string.StrUtils;
|
||||
import com.njcn.rdms.framework.ratelimiter.core.annotation.RateLimiter;
|
||||
import com.njcn.rdms.framework.ratelimiter.core.keyresolver.RateLimiterKeyResolver;
|
||||
import org.aspectj.lang.JoinPoint;
|
||||
|
||||
/**
|
||||
* 默认(全局级别)限流 Key 解析器,使用方法名 + 方法参数,组装成一个 Key
|
||||
*
|
||||
* 为了避免 Key 过长,使用 MD5 进行“压缩”
|
||||
*
|
||||
* @author hongawen
|
||||
*/
|
||||
public class DefaultRateLimiterKeyResolver implements RateLimiterKeyResolver {
|
||||
|
||||
@Override
|
||||
public String resolver(JoinPoint joinPoint, RateLimiter rateLimiter) {
|
||||
String methodName = joinPoint.getSignature().toString();
|
||||
String argsStr = StrUtils.joinMethodArgs(joinPoint);
|
||||
return SecureUtil.md5(methodName + argsStr);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package com.njcn.rdms.framework.ratelimiter.core.keyresolver.impl;
|
||||
|
||||
import cn.hutool.core.util.ArrayUtil;
|
||||
import com.njcn.rdms.framework.ratelimiter.core.annotation.RateLimiter;
|
||||
import com.njcn.rdms.framework.ratelimiter.core.keyresolver.RateLimiterKeyResolver;
|
||||
import org.aspectj.lang.JoinPoint;
|
||||
import org.aspectj.lang.reflect.MethodSignature;
|
||||
import org.springframework.core.DefaultParameterNameDiscoverer;
|
||||
import org.springframework.core.ParameterNameDiscoverer;
|
||||
import org.springframework.expression.Expression;
|
||||
import org.springframework.expression.ExpressionParser;
|
||||
import org.springframework.expression.spel.standard.SpelExpressionParser;
|
||||
import org.springframework.expression.spel.support.StandardEvaluationContext;
|
||||
|
||||
import java.lang.reflect.Method;
|
||||
|
||||
/**
|
||||
* 基于 Spring EL 表达式的 {@link RateLimiterKeyResolver} 实现类
|
||||
*
|
||||
* @author hongawen
|
||||
*/
|
||||
public class ExpressionRateLimiterKeyResolver implements RateLimiterKeyResolver {
|
||||
|
||||
private final ParameterNameDiscoverer parameterNameDiscoverer = new DefaultParameterNameDiscoverer();
|
||||
|
||||
private final ExpressionParser expressionParser = new SpelExpressionParser();
|
||||
|
||||
@Override
|
||||
public String resolver(JoinPoint joinPoint, RateLimiter rateLimiter) {
|
||||
// 获得被拦截方法参数名列表
|
||||
Method method = getMethod(joinPoint);
|
||||
Object[] args = joinPoint.getArgs();
|
||||
String[] parameterNames = this.parameterNameDiscoverer.getParameterNames(method);
|
||||
// 准备 Spring EL 表达式解析的上下文
|
||||
StandardEvaluationContext evaluationContext = new StandardEvaluationContext();
|
||||
if (ArrayUtil.isNotEmpty(parameterNames)) {
|
||||
for (int i = 0; i < parameterNames.length; i++) {
|
||||
evaluationContext.setVariable(parameterNames[i], args[i]);
|
||||
}
|
||||
}
|
||||
|
||||
// 解析参数
|
||||
Expression expression = expressionParser.parseExpression(rateLimiter.keyArg());
|
||||
return expression.getValue(evaluationContext, String.class);
|
||||
}
|
||||
|
||||
private static Method getMethod(JoinPoint point) {
|
||||
// 处理,声明在类上的情况
|
||||
MethodSignature signature = (MethodSignature) point.getSignature();
|
||||
Method method = signature.getMethod();
|
||||
if (!method.getDeclaringClass().isInterface()) {
|
||||
return method;
|
||||
}
|
||||
|
||||
// 处理,声明在接口上的情况
|
||||
try {
|
||||
return point.getTarget().getClass().getDeclaredMethod(
|
||||
point.getSignature().getName(), method.getParameterTypes());
|
||||
} catch (NoSuchMethodException e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package com.njcn.rdms.framework.ratelimiter.core.keyresolver.impl;
|
||||
|
||||
import cn.hutool.crypto.SecureUtil;
|
||||
import cn.hutool.system.SystemUtil;
|
||||
import com.njcn.rdms.framework.common.util.string.StrUtils;
|
||||
import com.njcn.rdms.framework.ratelimiter.core.annotation.RateLimiter;
|
||||
import com.njcn.rdms.framework.ratelimiter.core.keyresolver.RateLimiterKeyResolver;
|
||||
import org.aspectj.lang.JoinPoint;
|
||||
|
||||
/**
|
||||
* Server 节点级别的限流 Key 解析器,使用方法名 + 方法参数 + IP,组装成一个 Key
|
||||
*
|
||||
* 为了避免 Key 过长,使用 MD5 进行“压缩”
|
||||
*
|
||||
* @author hongawen
|
||||
*/
|
||||
public class ServerNodeRateLimiterKeyResolver implements RateLimiterKeyResolver {
|
||||
|
||||
@Override
|
||||
public String resolver(JoinPoint joinPoint, RateLimiter rateLimiter) {
|
||||
String methodName = joinPoint.getSignature().toString();
|
||||
String argsStr = StrUtils.joinMethodArgs(joinPoint);
|
||||
String serverNode = String.format("%s@%d", SystemUtil.getHostInfo().getAddress(), SystemUtil.getCurrentPID());
|
||||
return SecureUtil.md5(methodName + argsStr + serverNode);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package com.njcn.rdms.framework.ratelimiter.core.keyresolver.impl;
|
||||
|
||||
import cn.hutool.crypto.SecureUtil;
|
||||
import com.njcn.rdms.framework.common.util.string.StrUtils;
|
||||
import com.njcn.rdms.framework.ratelimiter.core.annotation.RateLimiter;
|
||||
import com.njcn.rdms.framework.ratelimiter.core.keyresolver.RateLimiterKeyResolver;
|
||||
import com.njcn.rdms.framework.web.core.util.WebFrameworkUtils;
|
||||
import org.aspectj.lang.JoinPoint;
|
||||
|
||||
/**
|
||||
* 用户级别的限流 Key 解析器,使用方法名 + 方法参数 + userId + userType,组装成一个 Key
|
||||
*
|
||||
* 为了避免 Key 过长,使用 MD5 进行“压缩”
|
||||
*
|
||||
* @author hongawen
|
||||
*/
|
||||
public class UserRateLimiterKeyResolver implements RateLimiterKeyResolver {
|
||||
|
||||
@Override
|
||||
public String resolver(JoinPoint joinPoint, RateLimiter rateLimiter) {
|
||||
String methodName = joinPoint.getSignature().toString();
|
||||
String argsStr = StrUtils.joinMethodArgs(joinPoint);
|
||||
Long userId = WebFrameworkUtils.getLoginUserId();
|
||||
Integer userType = WebFrameworkUtils.getLoginUserType();
|
||||
return SecureUtil.md5(methodName + argsStr + userId + userType);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package com.njcn.rdms.framework.ratelimiter.core.redis;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import org.redisson.api.RRateLimiter;
|
||||
import org.redisson.api.RateLimiterConfig;
|
||||
import org.redisson.api.RateType;
|
||||
import org.redisson.api.RedissonClient;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.Objects;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
/**
|
||||
* 限流 Redis DAO
|
||||
*
|
||||
* @author hongawen
|
||||
*/
|
||||
@AllArgsConstructor
|
||||
public class RateLimiterRedisDAO {
|
||||
|
||||
/**
|
||||
* 限流操作
|
||||
*
|
||||
* KEY 格式:rate_limiter:%s // 参数为 uuid
|
||||
* VALUE 格式:String
|
||||
* 过期时间:不固定
|
||||
*/
|
||||
private static final String RATE_LIMITER = "rate_limiter:%s";
|
||||
|
||||
private final RedissonClient redissonClient;
|
||||
|
||||
public Boolean tryAcquire(String key, int count, int time, TimeUnit timeUnit) {
|
||||
// 1. 获得 RRateLimiter,并设置 rate 速率
|
||||
RRateLimiter rateLimiter = getRRateLimiter(key, count, time, timeUnit);
|
||||
// 2. 尝试获取 1 个
|
||||
return rateLimiter.tryAcquire();
|
||||
}
|
||||
|
||||
private static String formatKey(String key) {
|
||||
return String.format(RATE_LIMITER, key);
|
||||
}
|
||||
|
||||
private RRateLimiter getRRateLimiter(String key, long count, int time, TimeUnit timeUnit) {
|
||||
String redisKey = formatKey(key);
|
||||
RRateLimiter rateLimiter = redissonClient.getRateLimiter(redisKey);
|
||||
long rateInterval = timeUnit.toSeconds(time);
|
||||
Duration duration = Duration.ofSeconds(rateInterval);
|
||||
// 1. 如果不存在,设置 rate 速率
|
||||
RateLimiterConfig config = rateLimiter.getConfig();
|
||||
if (config == null) {
|
||||
rateLimiter.trySetRate(RateType.OVERALL, count, duration);
|
||||
// 原因参见 https://t.zsxq.com/lcR0W
|
||||
rateLimiter.expire(duration);
|
||||
return rateLimiter;
|
||||
}
|
||||
// 2. 如果存在,并且配置相同,则直接返回
|
||||
if (config.getRateType() == RateType.OVERALL
|
||||
&& Objects.equals(config.getRate(), count)
|
||||
&& Objects.equals(config.getRateInterval(), TimeUnit.SECONDS.toMillis(rateInterval))) {
|
||||
return rateLimiter;
|
||||
}
|
||||
// 3. 如果存在,并且配置不同,则进行新建
|
||||
rateLimiter.setRate(RateType.OVERALL, count, duration);
|
||||
// 原因参见 https://t.zsxq.com/lcR0W
|
||||
rateLimiter.expire(duration);
|
||||
return rateLimiter;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
/**
|
||||
* 限流组件,基于 Redisson {@link org.redisson.api.RRateLimiter} 限流实现
|
||||
*/
|
||||
package com.njcn.rdms.framework.ratelimiter;
|
||||
@@ -0,0 +1,28 @@
|
||||
package com.njcn.rdms.framework.signature.config;
|
||||
|
||||
import com.njcn.rdms.framework.redis.config.RdmsRedisAutoConfiguration;
|
||||
import com.njcn.rdms.framework.signature.core.aop.ApiSignatureAspect;
|
||||
import com.njcn.rdms.framework.signature.core.redis.ApiSignatureRedisDAO;
|
||||
import org.springframework.boot.autoconfigure.AutoConfiguration;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.data.redis.core.StringRedisTemplate;
|
||||
|
||||
/**
|
||||
* HTTP API 签名的自动配置类
|
||||
*
|
||||
* @author Zhougang
|
||||
*/
|
||||
@AutoConfiguration(after = RdmsRedisAutoConfiguration.class)
|
||||
public class RdmsApiSignatureAutoConfiguration {
|
||||
|
||||
@Bean
|
||||
public ApiSignatureAspect signatureAspect(ApiSignatureRedisDAO signatureRedisDAO) {
|
||||
return new ApiSignatureAspect(signatureRedisDAO);
|
||||
}
|
||||
|
||||
@Bean
|
||||
public ApiSignatureRedisDAO signatureRedisDAO(StringRedisTemplate stringRedisTemplate) {
|
||||
return new ApiSignatureRedisDAO(stringRedisTemplate);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package com.njcn.rdms.framework.signature.core.annotation;
|
||||
|
||||
import com.njcn.rdms.framework.common.exception.enums.GlobalErrorCodeConstants;
|
||||
|
||||
import java.lang.annotation.*;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
|
||||
/**
|
||||
* HTTP API 签名注解
|
||||
*
|
||||
* @author Zhougang
|
||||
*/
|
||||
@Inherited
|
||||
@Documented
|
||||
@Target({ElementType.METHOD, ElementType.TYPE})
|
||||
@Retention(RetentionPolicy.RUNTIME)
|
||||
public @interface ApiSignature {
|
||||
|
||||
/**
|
||||
* 同一个请求多长时间内有效 默认 60 秒
|
||||
*/
|
||||
int timeout() default 60;
|
||||
|
||||
/**
|
||||
* 时间单位,默认为 SECONDS 秒
|
||||
*/
|
||||
TimeUnit timeUnit() default TimeUnit.SECONDS;
|
||||
|
||||
// ========================== 签名参数 ==========================
|
||||
|
||||
/**
|
||||
* 提示信息,签名失败的提示
|
||||
*
|
||||
* @see GlobalErrorCodeConstants#BAD_REQUEST
|
||||
*/
|
||||
String message() default "签名不正确"; // 为空时,使用 BAD_REQUEST 错误提示
|
||||
|
||||
/**
|
||||
* 签名字段:appId 应用ID
|
||||
*/
|
||||
String appId() default "appId";
|
||||
|
||||
/**
|
||||
* 签名字段:timestamp 时间戳
|
||||
*/
|
||||
String timestamp() default "timestamp";
|
||||
|
||||
/**
|
||||
* 签名字段:nonce 随机数,10 位以上
|
||||
*/
|
||||
String nonce() default "nonce";
|
||||
|
||||
/**
|
||||
* sign 客户端签名
|
||||
*/
|
||||
String sign() default "sign";
|
||||
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
package com.njcn.rdms.framework.signature.core.aop;
|
||||
|
||||
import cn.hutool.core.lang.Assert;
|
||||
import cn.hutool.core.map.MapUtil;
|
||||
import cn.hutool.core.util.BooleanUtil;
|
||||
import cn.hutool.core.util.ObjUtil;
|
||||
import cn.hutool.core.util.StrUtil;
|
||||
import cn.hutool.crypto.digest.DigestUtil;
|
||||
import com.njcn.rdms.framework.common.exception.ServiceException;
|
||||
import com.njcn.rdms.framework.common.exception.enums.GlobalErrorCodeConstants;
|
||||
import com.njcn.rdms.framework.common.util.servlet.ServletUtils;
|
||||
import com.njcn.rdms.framework.signature.core.annotation.ApiSignature;
|
||||
import com.njcn.rdms.framework.signature.core.redis.ApiSignatureRedisDAO;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.aspectj.lang.JoinPoint;
|
||||
import org.aspectj.lang.annotation.Aspect;
|
||||
import org.aspectj.lang.annotation.Before;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.SortedMap;
|
||||
import java.util.TreeMap;
|
||||
|
||||
import static com.njcn.rdms.framework.common.exception.enums.GlobalErrorCodeConstants.BAD_REQUEST;
|
||||
|
||||
/**
|
||||
* 拦截声明了 {@link ApiSignature} 注解的方法,实现签名
|
||||
*
|
||||
* @author Zhougang
|
||||
*/
|
||||
@Aspect
|
||||
@Slf4j
|
||||
@AllArgsConstructor
|
||||
public class ApiSignatureAspect {
|
||||
|
||||
private final ApiSignatureRedisDAO signatureRedisDAO;
|
||||
|
||||
@Before("@annotation(signature)")
|
||||
public void beforePointCut(JoinPoint joinPoint, ApiSignature signature) {
|
||||
// 1. 验证通过,直接结束
|
||||
if (verifySignature(signature, Objects.requireNonNull(ServletUtils.getRequest()))) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. 验证不通过,抛出异常
|
||||
log.error("[beforePointCut][方法{} 参数({}) 签名失败]", joinPoint.getSignature().toString(),
|
||||
joinPoint.getArgs());
|
||||
throw new ServiceException(BAD_REQUEST.getCode(),
|
||||
StrUtil.blankToDefault(signature.message(), BAD_REQUEST.getMsg()));
|
||||
}
|
||||
|
||||
public boolean verifySignature(ApiSignature signature, HttpServletRequest request) {
|
||||
// 1.1 校验 Header
|
||||
if (!verifyHeaders(signature, request)) {
|
||||
return false;
|
||||
}
|
||||
// 1.2 校验 appId 是否能获取到对应的 appSecret
|
||||
String appId = request.getHeader(signature.appId());
|
||||
String appSecret = signatureRedisDAO.getAppSecret(appId);
|
||||
Assert.notNull(appSecret, "[appId({})] 找不到对应的 appSecret", appId);
|
||||
|
||||
// 2. 校验签名【重要!】
|
||||
String clientSignature = request.getHeader(signature.sign()); // 客户端签名
|
||||
String serverSignatureString = buildSignatureString(signature, request, appSecret); // 服务端签名字符串
|
||||
String serverSignature = DigestUtil.sha256Hex(serverSignatureString); // 服务端签名
|
||||
if (ObjUtil.notEqual(clientSignature, serverSignature)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 3. 将 nonce 记入缓存,防止重复使用(重点二:此处需要将 ttl 设定为允许 timestamp 时间差的值 x 2 )
|
||||
String nonce = request.getHeader(signature.nonce());
|
||||
if (BooleanUtil.isFalse(signatureRedisDAO.setNonce(appId, nonce, signature.timeout() * 2, signature.timeUnit()))) {
|
||||
String timestamp = request.getHeader(signature.timestamp());
|
||||
log.info("[verifySignature][appId({}) timestamp({}) nonce({}) sign({}) 存在重复请求]", appId, timestamp, nonce, clientSignature);
|
||||
throw new ServiceException(GlobalErrorCodeConstants.REPEATED_REQUESTS.getCode(), "存在重复请求");
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验请求头加签参数
|
||||
* <p>
|
||||
* 1. appId 是否为空
|
||||
* 2. timestamp 是否为空,请求是否已经超时,默认 10 分钟
|
||||
* 3. nonce 是否为空,随机数是否 10 位以上,是否在规定时间内已经访问过了
|
||||
* 4. sign 是否为空
|
||||
*
|
||||
* @param signature signature
|
||||
* @param request request
|
||||
* @return 是否校验 Header 通过
|
||||
*/
|
||||
private boolean verifyHeaders(ApiSignature signature, HttpServletRequest request) {
|
||||
// 1. 非空校验
|
||||
String appId = request.getHeader(signature.appId());
|
||||
if (StrUtil.isBlank(appId)) {
|
||||
return false;
|
||||
}
|
||||
String timestamp = request.getHeader(signature.timestamp());
|
||||
if (StrUtil.isBlank(timestamp)) {
|
||||
return false;
|
||||
}
|
||||
String nonce = request.getHeader(signature.nonce());
|
||||
if (StrUtil.length(nonce) < 10) {
|
||||
return false;
|
||||
}
|
||||
String sign = request.getHeader(signature.sign());
|
||||
if (StrUtil.isBlank(sign)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 2. 检查 timestamp 是否超出允许的范围 (重点一:此处需要取绝对值)
|
||||
long expireTime = signature.timeUnit().toMillis(signature.timeout());
|
||||
long requestTimestamp = Long.parseLong(timestamp);
|
||||
long timestampDisparity = Math.abs(System.currentTimeMillis() - requestTimestamp);
|
||||
if (timestampDisparity > expireTime) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 3. 检查 nonce 是否存在,有且仅能使用一次
|
||||
return signatureRedisDAO.getNonce(appId, nonce) == null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建签名字符串
|
||||
* <p>
|
||||
* 格式为 = 请求参数 + 请求体 + 请求头 + 密钥
|
||||
*
|
||||
* @param signature signature
|
||||
* @param request request
|
||||
* @param appSecret appSecret
|
||||
* @return 签名字符串
|
||||
*/
|
||||
private String buildSignatureString(ApiSignature signature, HttpServletRequest request, String appSecret) {
|
||||
SortedMap<String, String> parameterMap = getRequestParameterMap(request); // 请求头
|
||||
SortedMap<String, String> headerMap = getRequestHeaderMap(signature, request); // 请求参数
|
||||
String requestBody = StrUtil.nullToDefault(ServletUtils.getBody(request), ""); // 请求体
|
||||
return MapUtil.join(parameterMap, "&", "=")
|
||||
+ requestBody
|
||||
+ MapUtil.join(headerMap, "&", "=")
|
||||
+ appSecret;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取请求头加签参数 Map
|
||||
*
|
||||
* @param request 请求
|
||||
* @param signature 签名注解
|
||||
* @return signature params
|
||||
*/
|
||||
private static SortedMap<String, String> getRequestHeaderMap(ApiSignature signature, HttpServletRequest request) {
|
||||
SortedMap<String, String> sortedMap = new TreeMap<>();
|
||||
sortedMap.put(signature.appId(), request.getHeader(signature.appId()));
|
||||
sortedMap.put(signature.timestamp(), request.getHeader(signature.timestamp()));
|
||||
sortedMap.put(signature.nonce(), request.getHeader(signature.nonce()));
|
||||
return sortedMap;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取请求参数 Map
|
||||
*
|
||||
* @param request 请求
|
||||
* @return queryParams
|
||||
*/
|
||||
private static SortedMap<String, String> getRequestParameterMap(HttpServletRequest request) {
|
||||
SortedMap<String, String> sortedMap = new TreeMap<>();
|
||||
for (Map.Entry<String, String[]> entry : request.getParameterMap().entrySet()) {
|
||||
sortedMap.put(entry.getKey(), entry.getValue()[0]);
|
||||
}
|
||||
return sortedMap;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package com.njcn.rdms.framework.signature.core.redis;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import org.springframework.data.redis.core.StringRedisTemplate;
|
||||
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
/**
|
||||
* HTTP API 签名 Redis DAO
|
||||
*
|
||||
* @author Zhougang
|
||||
*/
|
||||
@AllArgsConstructor
|
||||
public class ApiSignatureRedisDAO {
|
||||
|
||||
private final StringRedisTemplate stringRedisTemplate;
|
||||
|
||||
/**
|
||||
* 验签随机数
|
||||
* <p>
|
||||
* KEY 格式:signature_nonce:%s // 参数为 随机数
|
||||
* VALUE 格式:String
|
||||
* 过期时间:不固定
|
||||
*/
|
||||
private static final String SIGNATURE_NONCE = "api_signature_nonce:%s:%s";
|
||||
|
||||
/**
|
||||
* 签名密钥
|
||||
* <p>
|
||||
* HASH 结构
|
||||
* KEY 格式:%s // 参数为 appid
|
||||
* VALUE 格式:String
|
||||
* 过期时间:永不过期(预加载到 Redis)
|
||||
*/
|
||||
private static final String SIGNATURE_APPID = "api_signature_app";
|
||||
|
||||
// ========== 验签随机数 ==========
|
||||
|
||||
public String getNonce(String appId, String nonce) {
|
||||
return stringRedisTemplate.opsForValue().get(formatNonceKey(appId, nonce));
|
||||
}
|
||||
|
||||
public Boolean setNonce(String appId, String nonce, int time, TimeUnit timeUnit) {
|
||||
return stringRedisTemplate.opsForValue().setIfAbsent(formatNonceKey(appId, nonce), "", time, timeUnit);
|
||||
}
|
||||
|
||||
private static String formatNonceKey(String appId, String nonce) {
|
||||
return String.format(SIGNATURE_NONCE, appId, nonce);
|
||||
}
|
||||
|
||||
// ========== 签名密钥 ==========
|
||||
|
||||
public String getAppSecret(String appId) {
|
||||
return (String) stringRedisTemplate.opsForHash().get(SIGNATURE_APPID, appId);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
/**
|
||||
* HTTP API 签名,校验安全性
|
||||
*
|
||||
* @see <a href="https://pay.weixin.qq.com/wiki/doc/api/jsapi.php?chapter=4_3>微信支付 —— 安全规范</a>
|
||||
*/
|
||||
package com.njcn.rdms.framework.signature;
|
||||
@@ -0,0 +1,4 @@
|
||||
com.njcn.rdms.framework.idempotent.config.RdmsIdempotentConfiguration
|
||||
com.njcn.rdms.framework.lock4j.config.RdmsLock4jConfiguration
|
||||
com.njcn.rdms.framework.ratelimiter.config.RdmsRateLimiterConfiguration
|
||||
com.njcn.rdms.framework.signature.config.RdmsApiSignatureAutoConfiguration
|
||||
Reference in New Issue
Block a user