1.项目用到,不是核心
我们干系统开发,不免要考虑一个点,数据的重复提交。
我想我们之前如果要校验数据重复提交要求,会怎么干?会在业务层,对数据库操作,查询数据是否存在,存在就禁止插入数据; 但是吧,我们每次crud操作都会连接一次数据库,也就是占用内存,那么在项目中大量crud操作面前,我们通过这种方式来实现数据的重复提交,显然不大可取。因此我们采用通过 redis + 拦截器来实现防止数据重复提交。来分担数据库连接的压力。
数据重复提交有啥坏处?
- 数据完整性:如果用户在短时间内多次提交相同的表单,可能会导致数据重复或产生不一致的数据。
- 用户体验:如果用户不小心重复提交了表单,而系统没有进行相应的处理,用户可能会收到错误或重复的信息,这会影响用户体验。
- 性能考虑:大量的重复提交可能会对服务器造成不必要的负担,影响系统的性能。
- 安全考虑:在某些场景下,重复提交可能会被用于发起攻击,如DoS攻击。
我们要考虑一个事情,就是我们要验证数据的重复提交: 首先第一次提交的数据肯定是要被存储的,当而第二次往后,每次提交数据都会与之前的数据产生比对从而验证数据重复提交,但是通常情况下我们不仅要对提交数据重复性校验,还有前后提交时间差的校验。
下面,就有我通过redis + 拦截器来实现如何防止数据重复提交。
项目依赖
<dependencies><!--boot-web--><dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-web</artifactId></dependency><!--hutool--><dependency><groupId>cn.hutool</groupId><artifactId>hutool-all</artifactId><version>5.8.11</version><exclusions><exclusion><groupId>cn.hutool</groupId><artifactId>hutool-json</artifactId></exclusion></exclusions></dependency><!--fastJson2--><dependency><groupId>com.alibaba.fastjson2</groupId><artifactId>fastjson2</artifactId><version>2.0.19.graal</version></dependency><!--redis--><dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-data-redis</artifactId></dependency><!--commons-pools连接池,lettuce没有内置的数据库连接池所以要用第三方的 --><dependency><groupId>org.apache.commons</groupId><artifactId>commons-pool2</artifactId></dependency><!--boot-test--><dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-test</artifactId><scope>test</scope></dependency></dependencies><dependencyManagement><dependencies><dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-dependencies</artifactId><version>2.6.13</version><type>pom</type><scope>import</scope></dependency></dependencies></dependencyManagement>
application.yml
主要是redis的配置。
spring:# redis 配置redis:# 地址host: 192.168.233.131# 端口,默认为6379port: 6379# 数据库索引database: 0# 密码password:# 连接超时时间timeout: 10slettuce:pool:# 连接池中的最小空闲连接min-idle: 0# 连接池中的最大空闲连接max-idle: 8# 连接池的最大数据库连接数max-active: 8# #连接池最大阻塞等待时间(使用负值表示没有限制)max-wait: -1ms
FastJson2JsonRedisSerializer
主要负责对存入redis的key、value进行序列化。
/*** Redis使用FastJson序列化** @author jzm*/
public class FastJson2JsonRedisSerializer<T> implements RedisSerializer<T>
{public static final Charset DEFAULT_CHARSET = Charset.forName("UTF-8");static final Filter AUTO_TYPE_FILTER = JSONReader.autoTypeFilter(Constants.JSON_WHITELIST_STR);private Class<T> clazz;public FastJson2JsonRedisSerializer(Class<T> clazz){super();this.clazz = clazz;}@Overridepublic byte[] serialize(T t) throws SerializationException{if (t == null){return new byte[0];}return JSON.toJSONString(t, JSONWriter.Feature.WriteClassName).getBytes(DEFAULT_CHARSET);}@Overridepublic T deserialize(byte[] bytes) throws SerializationException{if (bytes == null || bytes.length <= 0){return null;}String str = new String(bytes, DEFAULT_CHARSET);return JSON.parseObject(str, clazz, AUTO_TYPE_FILTER);}
}
RedisConfig
redis相关配置。定义通过redisTemplate,设置到redis中的key、value的序列化方式。
/*** redis配置** @author jzm*/
@Configuration
@EnableCaching
public class RedisConfig extends CachingConfigurerSupport
{@Bean@SuppressWarnings(value = {"unchecked", "rawtypes"})// 设置key、value的序列化方式public RedisTemplate<Object, Object> redisTemplate(RedisConnectionFactory connectionFactory){RedisTemplate<Object, Object> template = new RedisTemplate<>();template.setConnectionFactory(connectionFactory);FastJson2JsonRedisSerializer serializer = new FastJson2JsonRedisSerializer(Object.class);// 使用StringRedisSerializer来序列化和反序列化redis的key值template.setKeySerializer(new StringRedisSerializer());template.setValueSerializer(serializer);// Hash的key也采用StringRedisSerializer的序列化方式template.setHashKeySerializer(new StringRedisSerializer());template.setHashValueSerializer(serializer);template.afterPropertiesSet();return template;}@Beanpublic DefaultRedisScript<Long> limitScript(){DefaultRedisScript<Long> redisScript = new DefaultRedisScript<>();redisScript.setScriptText(limitScriptText());redisScript.setResultType(Long.class);return redisScript;}/*** 限流脚本*/private String limitScriptText(){return "local key = KEYS[1]\n" +"local count = tonumber(ARGV[1])\n" +"local time = tonumber(ARGV[2])\n" +"local current = redis.call('get', key);\n" +"if current and tonumber(current) > count then\n" +" return tonumber(current);\n" +"end\n" +"current = redis.call('incr', key)\n" +"if tonumber(current) == 1 then\n" +" redis.call('expire', key, time)\n" +"end\n" +"return tonumber(current);";}
}
WebAppConfig
web mvc的相关配置。这里主要是注册自定义拦截器。
/*** web 配置** @author: jzm* @date: 2024-01-25 11:30**/@Configuration
public class WebAppConfig implements WebMvcConfigurer
{@Autowiredprivate SameUrlDataInterceptor sameUrlDataInterceptor;// 注册拦截器@Overridepublic void addInterceptors(InterceptorRegistry registry){// 可添加多个registry.addInterceptor(sameUrlDataInterceptor).addPathPatterns("/**");}
}
项目用到的常量类
常量类。直接CV。
/*** 缓存的key 常量** @author jzm*/
public class CacheConstants
{/*** 登录用户 redis key*/public static final String LOGIN_TOKEN_KEY = "login_tokens:";/*** 验证码 redis key*/public static final String CAPTCHA_CODE_KEY = "captcha_codes:";/*** 参数管理 cache key*/public static final String SYS_CONFIG_KEY = "sys_config:";/*** 字典管理 cache key*/public static final String SYS_DICT_KEY = "sys_dict:";/*** 防重提交 redis key*/public static final String REPEAT_SUBMIT_KEY = "repeat_submit:";/*** 限流 redis key*/public static final String RATE_LIMIT_KEY = "rate_limit:";/*** 登录账户密码错误次数 redis key*/public static final String PWD_ERR_CNT_KEY = "pwd_err_cnt:";
}
/*** 通用常量信息** @author jzm*/
public class Constants
{/*** UTF-8 字符集*/public static final String UTF8 = "UTF-8";/*** GBK 字符集*/public static final String GBK = "GBK";/*** www主域*/public static final String WWW = "www.";/*** http请求*/public static final String HTTP = "http://";/*** https请求*/public static final String HTTPS = "https://";/*** 通用成功标识*/public static final String SUCCESS = "0";/*** 通用失败标识*/public static final String FAIL = "1";/*** 登录成功*/public static final String LOGIN_SUCCESS = "Success";/*** 注销*/public static final String LOGOUT = "Logout";/*** 注册*/public static final String REGISTER = "Register";/*** 登录失败*/public static final String LOGIN_FAIL = "Error";/*** 所有权限标识*/public static final String ALL_PERMISSION = "*:*:*";/*** 管理员角色权限标识*/public static final String SUPER_ADMIN = "admin";/*** 角色权限分隔符*/public static final String ROLE_DELIMETER = ",";/*** 权限标识分隔符*/public static final String PERMISSION_DELIMETER = ",";/*** 验证码有效期(分钟)*/public static final Integer CAPTCHA_EXPIRATION = 2;/*** 令牌*/public static final String TOKEN = "token";/*** 令牌前缀*/public static final String TOKEN_PREFIX = "Bearer ";/*** 令牌前缀*/public static final String LOGIN_USER_KEY = "login_user_key";/*** 用户ID*/public static final String JWT_USERID = "userid";/*** 用户名称*/public static final String JWT_USERNAME = "sub";/*** 用户头像*/public static final String JWT_AVATAR = "avatar";/*** 创建时间*/public static final String JWT_CREATED = "created";/*** 用户权限*/public static final String JWT_AUTHORITIES = "authorities";/*** 资源映射路径 前缀*/public static final String RESOURCE_PREFIX = "/profile";/*** RMI 远程方法调用*/public static final String LOOKUP_RMI = "rmi:";/*** LDAP 远程方法调用*/public static final String LOOKUP_LDAP = "ldap:";/*** LDAPS 远程方法调用*/public static final String LOOKUP_LDAPS = "ldaps:";/*** 自动识别json对象白名单配置(仅允许解析的包名,范围越小越安全)*/public static final String[] JSON_WHITELIST_STR = {"org.springframework", "com.ruoyi"};/*** 定时任务白名单配置(仅允许访问的包名,如其他需要可以自行添加)*/public static final String[] JOB_WHITELIST_STR = {"com.ruoyi"};/*** 定时任务违规的字符*/public static final String[] JOB_ERROR_STR = {"java.net.URL", "javax.naming.InitialContext", "org.yaml.snakeyaml","org.springframework", "org.apache", "com.ruoyi.common.utils.file", "com.ruoyi.common.config"};
}
/*** 返回状态码* * @author jzm*/
public class HttpStatus
{/*** 操作成功*/public static final int SUCCESS = 200;/*** 对象创建成功*/public static final int CREATED = 201;/*** 请求已经被接受*/public static final int ACCEPTED = 202;/*** 操作已经执行成功,但是没有返回数据*/public static final int NO_CONTENT = 204;/*** 资源已被移除*/public static final int MOVED_PERM = 301;/*** 重定向*/public static final int SEE_OTHER = 303;/*** 资源没有被修改*/public static final int NOT_MODIFIED = 304;/*** 参数列表错误(缺少,格式不匹配)*/public static final int BAD_REQUEST = 400;/*** 未授权*/public static final int UNAUTHORIZED = 401;/*** 访问受限,授权过期*/public static final int FORBIDDEN = 403;/*** 资源,服务未找到*/public static final int NOT_FOUND = 404;/*** 不允许的http方法*/public static final int BAD_METHOD = 405;/*** 资源冲突,或者资源被锁*/public static final int CONFLICT = 409;/*** 不支持的数据,媒体类型*/public static final int UNSUPPORTED_TYPE = 415;/*** 系统内部错误*/public static final int ERROR = 500;/*** 接口未实现*/public static final int NOT_IMPLEMENTED = 501;/*** 系统警告消息*/public static final int WARN = 601;
}
项目用到的工具类
也是直接。CV。
RedisCache
/*** redis 工具类** @author jzm**/
@SuppressWarnings(value = {"unchecked", "rawtypes"})
@Component
public class RedisCache
{@Autowiredpublic RedisTemplate redisTemplate;/*** 缓存基本的对象,Integer、String、实体类等** @param key 缓存的键值* @param value 缓存的值*/public <T> void setCacheObject(final String key, final T value){redisTemplate.opsForValue().set(key, value);}/*** 缓存基本的对象,Integer、String、实体类等** @param key 缓存的键值* @param value 缓存的值* @param timeout 时间* @param timeUnit 时间颗粒度*/public <T> void setCacheObject(final String key, final T value, final Integer timeout, final TimeUnit timeUnit){redisTemplate.opsForValue().set(key, value, timeout, timeUnit);}/*** 设置有效时间** @param key Redis键* @param timeout 超时时间* @return true=设置成功;false=设置失败*/public boolean expire(final String key, final long timeout){return expire(key, timeout, TimeUnit.SECONDS);}/*** 设置有效时间** @param key Redis键* @param timeout 超时时间* @param unit 时间单位* @return true=设置成功;false=设置失败*/public boolean expire(final String key, final long timeout, final TimeUnit unit){return redisTemplate.expire(key, timeout, unit);}/*** 获取有效时间** @param key Redis键* @return 有效时间*/public long getExpire(final String key){return redisTemplate.getExpire(key);}/*** 判断 key是否存在** @param key 键* @return true 存在 false不存在*/public Boolean hasKey(String key){return redisTemplate.hasKey(key);}/*** 获得缓存的基本对象。** @param key 缓存键值* @return 缓存键值对应的数据*/public <T> T getCacheObject(final String key){ValueOperations<String, T> operation = redisTemplate.opsForValue();return operation.get(key);}/*** 删除单个对象** @param key*/public boolean deleteObject(final String key){return redisTemplate.delete(key);}/*** 删除集合对象** @param collection 多个对象* @return*/public boolean deleteObject(final Collection collection){return redisTemplate.delete(collection) > 0;}/*** 缓存List数据** @param key 缓存的键值* @param dataList 待缓存的List数据* @return 缓存的对象*/public <T> long setCacheList(final String key, final List<T> dataList){Long count = redisTemplate.opsForList().rightPushAll(key, dataList);return count == null ? 0 : count;}/*** 获得缓存的list对象** @param key 缓存的键值* @return 缓存键值对应的数据*/public <T> List<T> getCacheList(final String key){return redisTemplate.opsForList().range(key, 0, -1);}/*** 缓存Set** @param key 缓存键值* @param dataSet 缓存的数据* @return 缓存数据的对象*/public <T> BoundSetOperations<String, T> setCacheSet(final String key, final Set<T> dataSet){BoundSetOperations<String, T> setOperation = redisTemplate.boundSetOps(key);Iterator<T> it = dataSet.iterator();while (it.hasNext()){setOperation.add(it.next());}return setOperation;}/*** 获得缓存的set** @param key* @return*/public <T> Set<T> getCacheSet(final String key){return redisTemplate.opsForSet().members(key);}/*** 缓存Map** @param key* @param dataMap*/public <T> void setCacheMap(final String key, final Map<String, T> dataMap){if (dataMap != null){redisTemplate.opsForHash().putAll(key, dataMap);}}/*** 获得缓存的Map** @param key* @return*/public <T> Map<String, T> getCacheMap(final String key){return redisTemplate.opsForHash().entries(key);}/*** 往Hash中存入数据** @param key Redis键* @param hKey Hash键* @param value 值*/public <T> void setCacheMapValue(final String key, final String hKey, final T value){redisTemplate.opsForHash().put(key, hKey, value);}/*** 获取Hash中的数据** @param key Redis键* @param hKey Hash键* @return Hash中的对象*/public <T> T getCacheMapValue(final String key, final String hKey){HashOperations<String, String, T> opsForHash = redisTemplate.opsForHash();return opsForHash.get(key, hKey);}/*** 获取多个Hash中的数据** @param key Redis键* @param hKeys Hash键集合* @return Hash对象集合*/public <T> List<T> getMultiCacheMapValue(final String key, final Collection<Object> hKeys){return redisTemplate.opsForHash().multiGet(key, hKeys);}/*** 删除Hash中的某条数据** @param key Redis键* @param hKey Hash键* @return 是否成功*/public boolean deleteCacheMapValue(final String key, final String hKey){return redisTemplate.opsForHash().delete(key, hKey) > 0;}/*** 获得缓存的基本对象列表** @param pattern 字符串前缀* @return 对象列表*/public Collection<String> keys(final String pattern){return redisTemplate.keys(pattern);}
}
HttpHelper
主要是为了读取http请求体的数据。
/*** 通用http工具封装** @author ruoyi*/
public class HttpHelper
{private static final Logger LOGGER = LoggerFactory.getLogger(HttpHelper.class);public static String getBodyString(ServletRequest request){StringBuilder sb = new StringBuilder();BufferedReader reader = null;try (InputStream inputStream = request.getInputStream()){reader = new BufferedReader(new InputStreamReader(inputStream, StandardCharsets.UTF_8));String line = "";while ((line = reader.readLine()) != null){sb.append(line);}} catch (IOException e){LOGGER.warn("getBodyString出现问题!");} finally{if (reader != null){try{reader.close();} catch (IOException e){LOGGER.error("Exceptions:", e.getMessage());}}}return sb.toString();}
}
StringUtils
/*** 字符串工具类** @author jzm*/
public class StringUtils extends StrUtil
{/*** 空字符串*/private static final String NULLSTR = "";/*** 下划线*/private static final char SEPARATOR = '_';/*** 获取参数不为空值** @param value defaultValue 要判断的value* @return value 返回值*/public static <T> T nvl(T value, T defaultValue){return value != null ? value : defaultValue;}/*** * 判断一个Collection是否为空, 包含List,Set,Queue** @param coll 要判断的Collection* @return true:为空 false:非空*/public static boolean isEmpty(Collection<?> coll){return isNull(coll) || coll.isEmpty();}/*** * 判断一个Collection是否非空,包含List,Set,Queue** @param coll 要判断的Collection* @return true:非空 false:空*/public static boolean isNotEmpty(Collection<?> coll){return !isEmpty(coll);}/*** * 判断一个对象数组是否为空** @param objects 要判断的对象数组* * @return true:为空 false:非空*/public static boolean isEmpty(Object[] objects){return isNull(objects) || (objects.length == 0);}/*** * 判断一个对象数组是否非空** @param objects 要判断的对象数组* @return true:非空 false:空*/public static boolean isNotEmpty(Object[] objects){return !isEmpty(objects);}/*** * 判断一个Map是否为空** @param map 要判断的Map* @return true:为空 false:非空*/public static boolean isEmpty(Map<?, ?> map){return isNull(map) || map.isEmpty();}/*** * 判断一个Map是否为空** @param map 要判断的Map* @return true:非空 false:空*/public static boolean isNotEmpty(Map<?, ?> map){return !isEmpty(map);}/*** * 判断一个字符串是否为空串** @param str String* @return true:为空 false:非空*/public static boolean isEmpty(String str){return isNull(str) || NULLSTR.equals(str.trim());}/*** * 判断一个字符串是否为非空串** @param str String* @return true:非空串 false:空串*/public static boolean isNotEmpty(String str){return !isEmpty(str);}/*** * 判断一个对象是否为空** @param object Object* @return true:为空 false:非空*/public static boolean isNull(Object object){return object == null;}/*** * 判断一个对象是否非空** @param object Object* @return true:非空 false:空*/public static boolean isNotNull(Object object){return !isNull(object);}public static boolean inStringIgnoreCase(String str, String... strs){if (str != null && strs != null){for (String s : strs){if (str.equalsIgnoreCase(s)){return true;}}}return false;}
}
ServletUtils
客户端工具类
/*** 客户端工具类** @author Jzm*/
public class ServletUtils
{/*** 获取String参数*/public static String getParameter(String name){return getRequest().getParameter(name);}/*** 获取String参数*/public static String getParameter(String name, String defaultValue){return Convert.toStr(getRequest().getParameter(name), defaultValue);}/*** 获取Integer参数*/public static Integer getParameterToInt(String name){return Convert.toInt(getRequest().getParameter(name));}/*** 获取Integer参数*/public static Integer getParameterToInt(String name, Integer defaultValue){return Convert.toInt(getRequest().getParameter(name), defaultValue);}/*** 获取Boolean参数*/public static Boolean getParameterToBool(String name){return Convert.toBool(getRequest().getParameter(name));}/*** 获取Boolean参数*/public static Boolean getParameterToBool(String name, Boolean defaultValue){return Convert.toBool(getRequest().getParameter(name), defaultValue);}/*** 获得所有请求参数** @param request 请求对象{@link ServletRequest}* @return Map*/public static Map<String, String[]> getParams(ServletRequest request){final Map<String, String[]> map = request.getParameterMap();return Collections.unmodifiableMap(map);}/*** 获得所有请求参数** @param request 请求对象{@link ServletRequest}* @return Map*/public static Map<String, String> getParamMap(ServletRequest request){Map<String, String> params = new HashMap<>();for (Map.Entry<String, String[]> entry : getParams(request).entrySet()){params.put(entry.getKey(), StringUtils.join(",", entry.getValue()));}return params;}/*** 获取request*/public static HttpServletRequest getRequest(){return getRequestAttributes().getRequest();}/*** 获取response*/public static HttpServletResponse getResponse(){return getRequestAttributes().getResponse();}/*** 获取session*/public static HttpSession getSession(){return getRequest().getSession();}public static ServletRequestAttributes getRequestAttributes(){RequestAttributes attributes = RequestContextHolder.getRequestAttributes();return (ServletRequestAttributes) attributes;}/*** 将字符串渲染到客户端** @param response 渲染对象* @param string 待渲染的字符串*/public static void renderString(HttpServletResponse response, String string){try{response.setStatus(200);response.setContentType("application/json");response.setCharacterEncoding("utf-8");response.getWriter().print(string);} catch (IOException e){e.printStackTrace();}}/*** 是否是Ajax异步请求** @param request*/public static boolean isAjaxRequest(HttpServletRequest request){String accept = request.getHeader("accept");if (accept != null && accept.contains("application/json")){return true;}String xRequestedWith = request.getHeader("X-Requested-With");if (xRequestedWith != null && xRequestedWith.contains("XMLHttpRequest")){return true;}String uri = request.getRequestURI();if (StringUtils.inStringIgnoreCase(uri, ".json", ".xml")){return true;}String ajax = request.getParameter("__ajax");return StringUtils.inStringIgnoreCase(ajax, "json", "xml");}/*** 内容编码** @param str 内容* @return 编码后的内容*/public static String urlEncode(String str){try{return URLEncoder.encode(str, Constants.UTF8);} catch (UnsupportedEncodingException e){return StringUtils.EMPTY;}}/*** 内容解码** @param str 内容* @return 解码后的内容*/public static String urlDecode(String str){try{return URLDecoder.decode(str, Constants.UTF8);} catch (UnsupportedEncodingException e){return StringUtils.EMPTY;}}
}
项目用到的模型
AjaxResult: 公共响应类
/*** 操作消息提醒** @author jzm*/
public class AjaxResult extends HashMap<String, Object>
{private static final long serialVersionUID = 1L;/*** 状态码*/public static final String CODE_TAG = "code";/*** 返回内容*/public static final String MSG_TAG = "msg";/*** 数据对象*/public static final String DATA_TAG = "data";/*** 初始化一个新创建的 AjaxResult 对象,使其表示一个空消息。*/public AjaxResult(){}/*** 初始化一个新创建的 AjaxResult 对象** @param code 状态码* @param msg 返回内容*/public AjaxResult(int code, String msg){super.put(CODE_TAG, code);super.put(MSG_TAG, msg);}/*** 初始化一个新创建的 AjaxResult 对象** @param code 状态码* @param msg 返回内容* @param data 数据对象*/public AjaxResult(int code, String msg, Object data){super.put(CODE_TAG, code);super.put(MSG_TAG, msg);if (StringUtils.isNotNull(data)){super.put(DATA_TAG, data);}}/*** 返回成功消息** @return 成功消息*/public static AjaxResult success(){return AjaxResult.success("操作成功");}/*** 返回成功数据** @return 成功消息*/public static AjaxResult success(Object data){return AjaxResult.success("操作成功", data);}/*** 返回成功消息** @param msg 返回内容* @return 成功消息*/public static AjaxResult success(String msg){return AjaxResult.success(msg, null);}/*** 返回成功消息** @param msg 返回内容* @param data 数据对象* @return 成功消息*/public static AjaxResult success(String msg, Object data){return new AjaxResult(HttpStatus.SUCCESS, msg, data);}/*** 返回警告消息** @param msg 返回内容* @return 警告消息*/public static AjaxResult warn(String msg){return AjaxResult.warn(msg, null);}/*** 返回警告消息** @param msg 返回内容* @param data 数据对象* @return 警告消息*/public static AjaxResult warn(String msg, Object data){return new AjaxResult(HttpStatus.WARN, msg, data);}/*** 返回错误消息** @return 错误消息*/public static AjaxResult error(){return AjaxResult.error("操作失败");}/*** 返回错误消息** @param msg 返回内容* @return 错误消息*/public static AjaxResult error(String msg){return AjaxResult.error(msg, null);}/*** 返回错误消息** @param msg 返回内容* @param data 数据对象* @return 错误消息*/public static AjaxResult error(String msg, Object data){return new AjaxResult(HttpStatus.ERROR, msg, data);}/*** 返回错误消息** @param code 状态码* @param msg 返回内容* @return 错误消息*/public static AjaxResult error(int code, String msg){return new AjaxResult(code, msg, null);}/*** 是否为成功消息** @return 结果*/public boolean isSuccess(){return Objects.equals(HttpStatus.SUCCESS, this.get(CODE_TAG));}/*** 是否为警告消息** @return 结果*/public boolean isWarn(){return Objects.equals(HttpStatus.WARN, this.get(CODE_TAG));}/*** 是否为错误消息** @return 结果*/public boolean isError(){return Objects.equals(HttpStatus.ERROR, this.get(CODE_TAG));}/*** 方便链式调用** @param key 键* @param value 值* @return 数据对象*/@Overridepublic AjaxResult put(String key, Object value){super.put(key, value);return this;}
}
2.核心
RepeatSubmit
重复提交注解。主要用来设置前后提交数据时间差,至少要大于的时间差的上限。
/*** 自定义注解防止表单重复提交** @author jzm*/
@Inherited
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface RepeatSubmit
{/*** 间隔时间(ms),小于此时间视为重复提交*/public int interval() default 5000;/*** 提示消息*/public String message() default "不允许重复提交,请稍候再试";
}
RepeatSubmitInterceptor
我们重复提交拦截器的抽象类。我们主要把 preHandle()方法给实现了,但是具体判断是否重复提交的逻辑交给子类来实现。好处是,灵活度高,代码可读性强。当我们有其他相似功能拦截器需要实现时,也只需要继承该类即可。
/*** 拦截器** @author: jzm* @date: 2024-01-24 21:20**/public abstract class RepeatSubmitInterceptor implements HandlerInterceptor
{@Overridepublic boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler)throws Exception{if (handler instanceof HandlerMethod){HandlerMethod handlerMethod = (HandlerMethod) handler;Method method = handlerMethod.getMethod();RepeatSubmit repeatSubmit = method.getAnnotation(RepeatSubmit.class); // 能拿到处理方法if (repeatSubmit != null){if (this.isRepeatSubmit(request, repeatSubmit)) // 我们只有加了这个注解才表示限制重复提交{AjaxResult result = AjaxResult.error(repeatSubmit.message());ServletUtils.renderString(response, JSONUtil.toJsonStr(result));return false;}}return true;} else{return true;}}/*** 验证是否重复提交由子类实现具体的防重复提交的规则** @param request 请求信息* @param annotation 防重复注解参数* @return 结果* @throws Exception*/public abstract boolean isRepeatSubmit(HttpServletRequest request, RepeatSubmit annotation);
}
SameUrlDataInterceptor
我们要具体判断数据是否重复提交的子类。最后,将这个注入spring容器里面,然后我们在webmvc里面进行配置就可以正常使用了。
/*** 判断请求url和数据是否和上一次相同,* 如果和上次相同,则是重复提交表单。 有效时间为10秒内。** @author jzm*/
// 我们使用拦截器,防止重复提交
// 现在我们知道,为什么不用面向切面了? 切面需要拦截controller里面的方法,但是若依controller分布比较分散
// 用拦截器,会拦截controller的映射接口
// 首先,我们知道 Handler能够获得映射为方法的Method
@Component
public class SameUrlDataInterceptor extends RepeatSubmitInterceptor
{@AutowiredRedisCache redisCache;public final String header = "Authorization";public final String REPEAT_PARAMS = "repeatParams";public final String REPEAT_TIME = "repeatTime";@Overridepublic boolean isRepeatSubmit(HttpServletRequest request, RepeatSubmit annotation){String nowParams = "";// 拿请求body里面的内容// 拷贝副本--给拦截器读取 clone拷贝不显示,对于引用对象,是引用拷贝...,//if (request instanceof RequestReaderHttpServletRequestWrapper){RequestReaderHttpServletRequestWrapper requestWrapper = (RequestReaderHttpServletRequestWrapper) request;nowParams = HttpHelper.getBodyString(requestWrapper);}// body参数为空,获取Parameter的数据if (StringUtils.isEmpty(nowParams)){nowParams = JSONUtil.toJsonStr(request.getParameterMap());}// 当前数据映射,提交参数、提交时间Map<String, Object> nowDataMap = new HashMap<String, Object>();nowDataMap.put(REPEAT_PARAMS, nowParams);nowDataMap.put(REPEAT_TIME, System.currentTimeMillis());// 请求地址(作为存放cache的key值)String uri = request.getRequestURI();// 唯一值(没有消息头则使用请求地址)String submitKey = StringUtils.trimToEmpty(request.getHeader(header));// 唯一标识(指定key + url + 消息头)String cacheRepeatKey = CacheConstants.REPEAT_SUBMIT_KEY + uri + submitKey;// 如果 == null,代表提交过Object sessionObj = redisCache.getCacheObject(cacheRepeatKey);if (sessionObj != null){Map<String, Object> sessionMap = (Map<String, Object>) sessionObj;if (sessionMap.containsKey(uri)){Map<String, Object> preDataMap = (Map<String, Object>) sessionMap.get(uri);// 两次提交内容一致 && 提交时间间隔差 < 要求时间段if (compareParams(nowDataMap, preDataMap) && compareTime(nowDataMap, preDataMap, annotation.interval())){return true;}}}HashMap<String, Object> cacheMap = new HashMap<>();cacheMap.put(uri, nowDataMap);redisCache.setCacheObject(cacheRepeatKey, cacheMap, annotation.interval(), TimeUnit.MILLISECONDS);// 最后设置这上一个缓存对象重复提交时间return false;}/*** 判断参数是否相同*/private boolean compareParams(Map<String, Object> nowMap, Map<String, Object> preMap){String nowParams = (String) nowMap.get(REPEAT_PARAMS);String preParams = (String) preMap.get(REPEAT_PARAMS);return nowParams.equals(preParams);}/*** 判断两次间隔时间*/private boolean compareTime(Map<String, Object> nowMap, Map<String, Object> preMap, int interval){long time1 = (Long) nowMap.get(REPEAT_TIME);long time2 = (Long) preMap.get(REPEAT_TIME);if ((time1 - time2) < interval){return true;}return false;}
}
RequestReaderHttpServletRequestWrapper
关于为什么要这个东西呢?我们post请求,拦截器要预先读取HtppServletRequest里面的body的数据,是通过io的方式,都知道io读取完毕之后,之前的数据是变为null的,但是,当我么后面的接口来委派的时候,也是通过io读取body。这时候bodu里面是null的。那么鸡儿就会报io错。
因此我们需要这个类建立复制流
/*** 将请求包装,用来建立复制流** @author jzm*/
public class RequestReaderHttpServletRequestWrapper extends HttpServletRequestWrapper
{private final byte[] body;public RequestReaderHttpServletRequestWrapper(HttpServletRequest request) throws IOException{super(request);body = HttpHelper.getBodyString(request).getBytes(Charset.forName("UTF-8"));}@Overridepublic BufferedReader getReader() throws IOException{return new BufferedReader(new InputStreamReader(getInputStream()));}@Overridepublic ServletInputStream getInputStream() throws IOException{final ByteArrayInputStream bais = new ByteArrayInputStream(body);return new ServletInputStream(){@Overridepublic int read() throws IOException{return bais.read();}@Overridepublic boolean isFinished(){return false;}@Overridepublic boolean isReady(){return false;}@Overridepublic void setReadListener(ReadListener readListener){}};}
}
3.测试
测试用到的to
/*** 测试to** @author: jzm* @date: 2024-01-25 14:26**/public class TestTo
{public TestTo(){}public String getName(){return name;}public void setName(String name){this.name = name;}public Integer getAge(){return age;}public void setAge(Integer age){this.age = age;}private String name;private Integer age;}
我们新建controller用来测试。分别对get路径参数、post请求体中的数据进行来校验。
*** 测试控制器** @author: jzm* @date: 2024-01-25 11:10**/@RestController
@ResponseBody
public class BaseController
{private Logger log = LoggerFactory.getLogger(BaseController.class);@RequestMapping(value = "/get/test", method = {RequestMethod.GET})@RepeatSubmit(interval = 10 * 1000, message = "对不起您重复提交get请求!")public AjaxResult getTest(@RequestParam("name") String name, @RequestParam("age") Integer age){String res = "get_test:" + name + age;log.info(res);return AjaxResult.success(res);}@RequestMapping(value = "/post/test", method = {RequestMethod.POST})@RepeatSubmit(interval = 10 * 1000, message = "对不起重复提交post请求")public AjaxResult postTest(@RequestBody TestTo testTo){String res = "post_test" + testTo.getName() + testTo.getAge();log.info(res);return AjaxResult.success(res);}
}
我们启动项目,利用Apifox 来进行测试:
这时候打开Resp:免费Redis图形化界面(RESP)下载地址和连接步骤_resp下载-CSDN博客
发现数据是成功存入的,剩余7s过期,在10s之内,也就是数据没过期之前,在发送一次。
因此,确实数据重复提交了。
post请求测试。