如何防止系统的表单被重复提交?
By: Date: 2024年5月7日 Categories: 程序 标签:,

防止重复提交是一个系统必须要做的一个事情。在客户端页面,我们可以通过点击按钮后设置disabled等方式来进行处理,在这里我们不做讨论。但真正的重复提交,是后端服务必须要校验住的,我们不希望用户连续点击了两次按钮,就在我们的系统中产生了两条重复的记录,这是无意义的,也是用户不希望发生的,因此对于一个健壮的系统,我们就要考虑这样的场景。

有这么一个场景,我们在录入单据之后,系统发现已经有相似的单据,客户端会提示用户并确认用户是否要继续录入,这时如果继续录入就不算做是重复提交对吗?相反我们的系统还要识别出这是不同的操作来确保第二次提交能够通过防重校验。

好,对于这种问题,我们通常的做法是通过后端设置防重key来过滤掉重复的操作,当然我们要识别出上述正常的业务场景中哪些是重复的点击,哪些是正常的操作,多久之后允许再次操作提交,以及防重key的生成规则等。

针对上述所提出的问题,我们就要给出解决方案,所以我设置了默认的防重提交时间是3秒,也就是3秒之后才能再次提交。
在上面的场景中,我们可以在表单中增加了一个强制提交的属性,用来标识是否是第二次提交。
其次,对于防重Key的生成,两次的请求如何来确唯一呢?那我们的防重Key中就一定要包含当前的操作人,以及表单的部分重要信息来作为标识是否相同操作,这里我们以表单中的ID属性及强制提交属性来作为防重Key的组成元素,这样对两次不同的提交操作都能够进行有效的防重。


首先我们定义一个注解,用来标记在需要防重复提交的方法上。这个注解包含两个属性,一个是延迟时间,简单说来就是代表首次提交之后多久可以再次提交,我们设置默认值默认3秒。
另外一个就是防重Key的后缀,我们不仅让它支持基本类型的变量参数,也让他支持复杂对象的属性#object.id 这样的写法,这样不同的方法上,我们都能够动态兼容不同的Key值了。

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface NoResubmit {
    /**
     * 延时时间 在延时多久后可以再次提交,默认3秒
     * @return 秒
     */
    int delaySeconds() default 3;

    /**
     * 唯一Key后缀,支持 #object.id 写法
     * @return
     */
    String suffixKey() default "0";
}

第二步我们要定义一个切面,使得调用到这个方法的时候,做我们的防重校验。代码很简单,具体分为三步:
1.获取当前请求调用的目标方法,以及方法上标记的注解。
2.根据注解中设置的参数生成唯一的防重Key,这里是关键一步。
3.根据防重Key,进行加锁操作。

@Aspect
@Component
public class LockMethodAspect {
    @Resource
    private RedisLock redisLock;

    /**
     * 防重复提交拦截过滤
     * @param joinPoint
     * @return
     * @throws Throwable
     */
    @Around("execution(* com.st.sfinetrip.web..*.*(..)) && @annotation(com.st.sfinetrip.config.component.verification.NoResubmit)")
    public Object interceptor(ProceedingJoinPoint joinPoint) throws Throwable {
        // 1. 获取注解
        MethodSignature signature = (MethodSignature) joinPoint.getSignature();
        Method method = signature.getMethod();
        NoResubmit lock = method.getAnnotation(NoResubmit.class);

        // 2. 根据注解的参数,生成防重Key
        final String lockKey = generateKey(joinPoint,lock,method);
        final String md5LockKey= DigestUtils.md5DigestAsHex(lockKey.getBytes(Charset.defaultCharset()));

        // 3. 上锁
        final boolean success = redisLock.rLock(md5LockKey, null, lock.delaySeconds(), TimeUnit.SECONDS);
        if (!success) {
            log.info("获取防重复提交锁失败,key={}",lockKey);
            return Result.error("操作太频繁!");
        }
        return joinPoint.proceed();
    }
    //...
}

获取方法的注解,我们就不说了,这里来说说获取注解中的参数来生成防重Key的部分。这里我们做了一个特殊的处理,就是判断注解的参数中,是否设置了suffixKey属性,如果设置了,我们就去做解析,并把解析出来的内容放在我们防重Key的后面作为后缀。如果没有设置,那我们直接将当前用户的登录账号作为防重Key的元素即可,从返回值看,我们的防重Key的组成就是类名+方法名+登录用户ID+动态后缀。

    /**
    * 生成防重Key
    * @param joinPoint
    * @param lock
    * @return
    */
    private String generateKey(ProceedingJoinPoint joinPoint,NoResubmit lock,Method method) {
        //获取目标所在类名
        String className = joinPoint.getTarget().getClass().getSimpleName();

        String suffixKey = null;
        if (!StringUtils.isEmpty(lock.suffixKey())) {
            //有后缀的情况
            suffixKey = this.generateSuffixKey(lock.suffixKey(), method, joinPoint.getArgs());
        }
        String userId;
        if (UserContext.getCurrentUser() != null && UserContext.getCurrentUser().getUserId() != null) {
            userId = UserContext.getCurrentUser().getAccount();
        } else {
            userId = Constants.GUEST_USER;
        }
        //返回防重Key
        return String.format("%s_%s_%s_%s", className, method.getName(), userId, suffixKey);
    }

这里就是关键的地方,如果调用的目标方法上的参数都是简单的基本类型,那么我们生成防重Key就会简单很多,但是如果是复杂类型,并且它的属性恰好又设置在了我们注解的后缀属性里,这是我们解析它就会相对复杂一些。我们要取出这个复杂对象的指定属性,甚至是多个属性来生成防重Key,下面直接来看代码。

    /**
     * 获取唯一key后缀
     * @param suffixKey
     * @param method
     * @param requestParameters
     * @return
     */
    private String generateSuffixKey(String suffixKey,Method method,Object[] requestParameters) {
        String suffixKeyResult = suffixKey;
        //获取后缀中,所有的形如object.id的字符串
        List<String> suffixParamList = StringUtils.getReSubmitParams(suffixKey);
        if (!CollectionUtils.isEmpty(suffixParamList)) {
            //根据发现器来获取方法的参数
            LocalVariableTableParameterNameDiscoverer discoverer = new LocalVariableTableParameterNameDiscoverer();
            discoverer.getParameterNames(method);
            String[] methodParameters = discoverer.getParameterNames(method); //method.getParameters();
            if (methodParameters != null) {
                for (String suffixParam : suffixParamList) {
                    //这里我们解析#object.id这样的后缀定义
                    String[] varName = suffixParam.substring(1).split("\\.");
                    int index = 0;
                    for (String methodParamName : methodParameters) {
                        if (methodParamName.equals(varName[0])) {
                            if (varName.length > 1) {
                                //大于1,则表示从复杂对象中取属性值
                                suffixKeyResult = suffixKeyResult.replace(suffixParam, this.getPropertyValueFromObject(requestParameters[index], varName, 1));
                            } else {
                                //否则表示直接去方法的参数变量
                                suffixKeyResult = suffixKeyResult.replace(suffixParam, requestParameters[index] == null ? requestParameters[index].toString() : "null");
                            }
                            break;
                        }
                        index++;
                    }
                }
            }
        }
        return suffixKeyResult;
    }

这里又用到了一个方法StringUtils.getReSubmitParams,这是我们写了一个正则方法,用于获取自定义注解中,后缀属性里的复杂对象属性。

    /**
     * 匹配自定义变量,形如:#object.id-#object.forceFlag中的object.id,object.forceFlag
     */
    private static Pattern reSubmitParamPattern = Pattern.compile("(#[a-z][a-zA-Z0-9]*[\\.a-z]?[a-zA-Z0-9]*)");

    /**
     * 匹配防重复提交注解后缀中参数变量
     *
     * @param content
     * @return
     */
    public static List<String> getReSubmitParams(String content) {
        return getMatcherList(reSubmitParamPattern,content);
    }

    private static List<String> getMatcherList(Pattern pattern,String content){
        Matcher matcher = pattern.matcher(content);
        List<String> result = new ArrayList<String>();
        while (matcher.find()) {
            result.add(matcher.group());
        }
        return result;
    }

当我们拿到注解中,后缀所定义的所有属性后,我们要做的,就是从用真实的对象属性的Value来替换掉它。从对象中获取属性值就比较简单了,直接上代码。

    /**
     * 获取对象的属性值
     * @param value
     * @param propNames
     * @param startPropIndex
     * @return
     */
    private String getPropertyValueFromObject(Object value, String[] propNames, int startPropIndex){
        if(value!=null) {
            try {
                Class<?> valueClass= value.getClass();
                Field field;
                try {
                    field = valueClass.getDeclaredField(propNames[startPropIndex]);
                }catch (Exception e){
                    valueClass = valueClass.getSuperclass();
                    field = valueClass.getDeclaredField(propNames[startPropIndex]);
                }
                field.setAccessible(Boolean.TRUE);
                Object fieldValue = field.get(value);
                if (fieldValue != null) {
                    if (startPropIndex >= propNames.length - 1) {
                        return fieldValue.toString();
                    } else {
                        return getPropertyValueFromObject(fieldValue, propNames, startPropIndex + 1);
                    }
                }
            } catch (Exception e) {
                log.error("获取属性值失败!", e);
            }
        }
        return "null";
    }

好了至此,我们就把如何解析注解中的后缀参数,以及生成防重Key说清楚了,那么上锁就简单了,一个Redis的乐观锁就搞定了。


接下来看看我们的方法应该怎样来加这个注解,只需要在方法上添加@NoResubmit(suffixKey = "#trip.id_#trip.forceSaveFlag"),就可以实现我们想要的效果。

@RestController
@RequestMapping("/travel/trip")
public class TripController {
    @NoResubmit(suffixKey = "#trip.id_#trip.forceSaveFlag")
    @RequestMapping(value="/save",method = {RequestMethod.POST})
    public Result save(@RequestBody Trip trip){
        return tripService.saveTrip(trip);
    }
}

效果如下:
防止页面重复提交

发表回复

您的电子邮箱地址不会被公开。 必填项已用 * 标注