订单防重复提交:token 发放以及校验
- 1. 基于Token校验避免订单重复提交
1. 基于Token校验避免订单重复提交
在很多秒杀场景中,用户为了能下单成功,会频繁的点击下单按钮,这时候如果没有做好控制的话,就可能会给一个用户创建重复订单。
如何防止这个问题呢?
其实有一个好办法,那就是用户在下单的时候,带一个 token 过来,我们校验这个 token 的有效性,如果 token 有效,则允许下单,如果无效,则不允许用户下单。
注意注意注意:这里的 token 和 sa-token(鉴权token) 这个框架中的 token 不是一回事儿,也没有任何关系。
sa-token 里面的那个 token是用于登录鉴权的。
而这里的 token 是用来防止订单重复提交的,他俩不是一个 token,这里的 token 也不是 sa-token 发放的,而是我们自己实现的一个发放和存储,以及后续的校验,都是我们自己做的。
那么,这个 token 是如何发放和校验的的呢?
token 的发放比较简单,我们定义一个 controller,在下单页面渲染的时候从接口中获取一下就行了。
package cn.hollis.nft.turbo.web.filter;
import jakarta.servlet.*;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.redisson.api.RScript;
import org.redisson.api.RedissonClient;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.IOException;
import java.util.Arrays;
public class TokenFilter implements Filter {
private static final Logger logger = LoggerFactory.getLogger(TokenFilter.class);
public static final ThreadLocal<String> tokenThreadLocal = new ThreadLocal<>();
private RedissonClient redissonClient;
public TokenFilter(RedissonClient redissonClient) {
this.redissonClient = redissonClient;
}
@Override
public void init(FilterConfig filterConfig) throws ServletException {
// 过滤器初始化,可选实现
}
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
try {
HttpServletRequest httpRequest = (HttpServletRequest) request;
HttpServletResponse httpResponse = (HttpServletResponse) response;
// 从请求头中获取Token
String token = httpRequest.getHeader("Authorization");
logger.info("TokenFilter::doFilter,httpRequest:{}", httpRequest);
logger.info("TokenFilter::doFilter,token:{}", token);
if (token == null || "null".equals(token) || "undefined".equals(token)) {
httpResponse.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
httpResponse.getWriter().write("No Token Found ...");
logger.error("no token found in header , pls check!");
return;
}
// 校验Token的有效性
boolean isValid = checkTokenValidity(token);
if (!isValid) {
httpResponse.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
httpResponse.getWriter().write("Invalid or expired token");
logger.error("token validate failed , pls check!");
return;
}
// Token有效,继续执行其他过滤器链
chain.doFilter(request, response);
} finally {
tokenThreadLocal.remove();
}
}
/**
* 检查Token的有效性
* 通过Redis判断Token是否存在,并将其删除
* 这个方法用于确保Token的单次使用,增强安全性
*
* @param token 要检查的Token
* @return 如果Token在Redis中存在,则返回true;否则返回false
*/
private boolean checkTokenValidity(String token) {
// Lua脚本,用于获取并删除Redis中的Token
// 这样做是为了保证Token的单次使用,增强安全性
String luaScript = """
local value = redis.call('GET', KEYS[1])
redis.call('DEL', KEYS[1])
return value""";
// 6.2.3以上可以直接使用GETDEL命令
// String value = (String) redisTemplate.opsForValue().getAndDelete(token);
// 使用Redisson客户端执行Lua脚本,判断Token是否存在并将其删除
// KEYS[1]表示脚本中的Token键
// getScript这个方法用于获取一个脚本对象(通常用于处理 Redis 中的脚本相关操作)。
// 在 Redis 中,可以使用 Lua 脚本进行复杂的操作,例如原子性地执行多个命令等。Redisson 通过这个方法提供了对脚本操作的支持。
// eval 具体来说,它会将脚本发送到 Redis 服务器端执行,并且可以根据脚本的逻辑在
// Redis 中进行数据操作(如读取、写入、修改数据等),脚本执行的结果会被返回给调用者(在 Java 中就是返回给执行eval()方法的地方)。
// 例如,如果脚本是一个用于计算某个键对应的值的两倍的 Lua 脚本,eval()方法执行这个脚本后就会得到计算后的结果并返回。
String result = (String) redissonClient.getScript().eval(RScript.Mode.READ_WRITE,
luaScript,
RScript.ReturnType.STATUS,
Arrays.asList(token));
// 将Redis中的返回值存储到ThreadLocal中,以便在当前线程内其他地方使用
tokenThreadLocal.set(result);
// 如果result不为空,说明Token在Redis中存在过,即Token有效
return result != null;
}
@Override
public void destroy() {
}
}
主要实现在doFilter方法中,主要是判断请求中是否携带了 token,如果携带了,通过 redis 校验 token 是否有效,如果有效,则把这个 token 删除,并且放过请求。如果无效,则直接拒绝请求。
这里的token 校验及移除,我们是通过 lua 脚本实现的,保证原子性。
有了这个 filter 之后,我们需要让他能够生效,则需要以下配置:
import cn.hollis.nft.turbo.web.filter.TokenFilter;
import cn.hollis.nft.turbo.web.handler.GlobalWebExceptionHandler;
import org.redisson.api.RedissonClient;
import org.springframework.boot.autoconfigure.AutoConfiguration;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication;
import org.springframework.boot.web.servlet.FilterRegistrationBean;
import org.springframework.context.annotation.Bean;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
/**
* Web配置类,用于配置全局异常处理器和Token过滤器
*
* @AutoConfiguration注解
* 来源与功能概述
* 在 Spring Boot 框架中,@AutoConfiguration是一个非常重要的注解。它用于标识一个类是自动配置类。
* Spring Boot 的自动配置机制会根据类路径中的依赖和预定义的配置条件自动加载和应用这些自动配置类。
* 工作原理
* 自动配置类通常包含一系列的@Bean方法,这些方法会向 Spring 容器中注入各种组件。
* 例如,当项目的类路径中存在某些特定的库(如数据库驱动)时,相关的自动配置类就会被触发,
* 它内部的@Bean方法会创建和配置与该库相关的组件,如数据源、事务管理器等,从而减少了开发者手动配置这些组件的工作量。
*
* @ConditionalOnWebApplication注解
* 条件注解类型
* 这是 Spring Boot 中的一个条件注解。条件注解用于根据特定的条件来决定是否加载某个配置类或者某个@Bean方法。
* 针对 Web 应用的条件判断
* @ConditionalOnWebApplication用于判断当前应用是否是一个 Web 应用。它有不同的匹配模式:
* 如果没有指定任何模式,只要是 Web 应用(无论是 Servlet - based 还是 Reactive - based)就会满足条件。
* type = ConditionalOnWebApplication.Type.SERVLET:这种模式下,只有当应用是基于 Servlet 的 Web 应用时才会满足条件。例如,在传统的 Spring MVC 应用中,
* 使用 Servlet 容器(如 Tomcat、Jetty 等)来处理 HTTP 请求,就属于这种情况。
* type = ConditionalOnWebApplication.Type.REACTIVE:只有当应用是基于响应式(Reactive)的 Web 应用时才满足条件,如使用 Spring WebFlux 构建的应用,
* 它采用响应式编程模型来处理 HTTP 请求。
* 这些注解共同作用,使得 Spring Boot 能够根据应用的实际情况(如是否为 Web 应用等)智能地加载和配置相关的组件,提高了应用开发的效率和灵活性。
*
* @author Hollis
*/
@AutoConfiguration
@ConditionalOnWebApplication
public class WebConfiguration implements WebMvcConfigurer {
/**
* 配置全局Web异常处理处理器
*
* @return GlobalWebExceptionHandler 全局异常处理器实例
*/
@Bean
@ConditionalOnMissingBean
GlobalWebExceptionHandler globalWebExceptionHandler() {
return new GlobalWebExceptionHandler();
}
/**
* 注册Token过滤器
*
* @param redissonClient Redisson客户端实例,用于分布式锁等Redis操作
* @return FilterRegistrationBean<TokenFilter> 注册的Token过滤器实例
*/
@Bean
public FilterRegistrationBean<TokenFilter> tokenFilter(RedissonClient redissonClient) {
FilterRegistrationBean<TokenFilter> registrationBean = new FilterRegistrationBean<>();
// 初始化TokenFilter实例并设置Redisson客户端
registrationBean.setFilter(new TokenFilter(redissonClient));
// 设置过滤器处理的URL模式
registrationBean.addUrlPatterns("/trade/buy");
// 设置过滤器顺序
registrationBean.setOrder(10);
return registrationBean;
}
}
这里,我们并不是给所有的页面都加这个 token 的校验,其实很多接口是不需要的,所以我们只需要通过registrationBean.addUrlPatterns(“/trade/buy”);设置上我们需要校验的路径就行了。
前端代码:
latestCollectionCreateOrder() {
var that = this;
this.$u.post('/trade/buy', {
goodsId: that.collectionId,
goodsType: 'COLLECTION',
itemCount: 1,
token: that.checkToken
}, {
Authorization: that.checkToken
}).then(res => {
if(res.success) {
that.orderId = res.data;
that.showPayModalFlag = true;
}else{
uni.showToast({
icon: 'error',
title: res.message,
duration: 2000
});
}
}).catch(error => {
if (error.statusCode === 401) {
uni.showModal({
title: '请勿重复提交',
content: ' 请刷新页面重新发起请求',
showCancel: false,
success: function (res) {
if (res.confirm) {
// Handle the case when user confirms the modal
// For example, you can redirect to the login page
}
}
});
} else {
// Handle other errors
uni.showToast({
icon: 'error',
title: 'An error occurred',
duration: 2000
});
}
});
},