表单校验之多字段联合校验唯一

2,244 阅读3分钟

在做后台管理系统的过程中,有时候要和表单唯一校验打交道。而每次唯一校验的逻辑都是大同小异的,这里参考Jeecg-Boot框架提供的单字段校验的思路来实现多字段校验,主要总结唯一校验的大概思路。

唯一校验的基本思路

  1. 页面触发

    在表单输入变化(onChange)或者失去焦点(onBlur)的时候触发唯一校验,发起请求,把查询哪个表,以及查询条件封装在请求参数里。

  2. 后端验证

    接收到表名和查询条件之后,先进行SQL注入安全校验,再使用SQL注入表名、查询条件的方式来进行查询。(SQL注入安全校验不可少)

  3. 页面显示

    用表单自定义校验的方式,在接收到请求响应结果之后,如果唯一校验不通过则显示该值已经存在。

代码实现

  1. 页面触发(介绍react和vue版本)

    • react版

      1. 定义异步验证请求
      import {httpAction} from "@/utils/Request";
      import {HTTP_METHODS} from "@/common";
      
      export const validateDuplicateManyValueApi = (params) => httpAction(`/sys/duplicate/checkMany`, params, HTTP_METHODS.POST);
      
      
      1. 封装验证工具函数

        这里需要注意两个点:

        1. 这里是需要传入一条记录的id的,因为后端验证的时候通过这个参数判断这是新增时的唯一验证,还是编辑时的唯一验证。因为这两种存在着不同,新增的验证是校验数据库不能存在和传入参数相等的数据,而编辑是校验除了这个id以外的数据不能和传入参数相等
        2. reactant-design(4.17)的表单检验 和 vueant-design-vue(1.7)的自定义校验是不一样。react版已经升级到了Promise的方式,而vue版是callback回调函数的方式(vue3版升级到了Promise
      import {validateDuplicateManyValueApi} from "@/api/Dict";
      ​
      /**
       * 根据${fieldMap}和${dataId}设置为查询条件,查询表名为${tableName}的数据。
       * 这里为什么没有把fieldMap和dataId合并为一个参数?因为他们逻辑是不同的,fieldMap是查询条件,dataId是用于判读新增还是编辑的校验
       * @param tableName
       * @param fieldMap
       * @param dataId
       */
      export const validateDuplicateManyValue = async ({tableName, fieldMap, dataId}) => {
          // 定义成功和失败的Promise
          const success = Promise.resolve();
          const fail = (failMsg) => Promise.reject(failMsg);
          // 返回结果
          let promise;
          // 首先验证fieldMap里的每个key都有值, 这里不用(!fieldMap[key])的方式判断空,避免假值中0被包含
          let existFalse = Object.keys(fieldMap).some(key => fieldMap[key] === null || fieldMap[key] === undefined);
          // 存在undefined或者null,不发起请求
          if (existFalse) {
              promise = success;
          } else {
              try {
                  let res = await validateDuplicateManyValueApi({tableName, fieldMap, dataId});
                  promise = res.success ? success : fail(res.message);
              } catch (err) {
                  promise = fail(err.message || err);
              }
          }
          return promise;
      };
      ​
      
      1. 函数组件里使用

         const [form] = Form.useForm(); 
        // 异步联合验证编码和名称唯一
            const validateRoleCodeAndRoleName = () => {
                let fieldsValue = form.getFieldsValue();
                let params = {
                    tableName: "sys_role",
                    dataId: model.id,
                    fieldMap: {
                        role_code: fieldsValue.roleCode,
                        role_name: fieldsValue.roleName,
                    },
                };
                return validateDuplicateManyValue(params);
            };
            // 验证规则
            const validateRules = {
                roleCode: [
                    {required: true},
                ],
                roleName: [
                    {required: true},
                    {validator: validateRoleCodeAndRoleName},
                ],
            };
           
        ​
             return (
                <Modal
                    title="添加角色"
                    width={600}
                    visible={modalVisible}
                    onOk={hiddenModal}
                    onCancel={hiddenModal}>
                    <Form
                        form={form}
                        autoComplete={"off"}
                        {...formItemLayout}
                    >
                        <Form.Item name="roleCode" label="角色编码" rules={validateRules.roleCode}>
                            <Input/>
                        </Form.Item>
                        <Form.Item name="roleName" validateTrigger={["onBlur"]} label="角色名称" rules=                 {validateRules.roleName}>
                            <Input/>
                        </Form.Item>
                        <Form.Item name="description" label="描述">
                            <Input.TextArea/>
                        </Form.Item>
                    </Form>
                </Modal>
            );
        
    • vue版

      主要是工具函数和组件调用方式不同

      1. 工具函数

        需要多传入一个callback参数

        export function validateDuplicateManyValue(tableName, fieldMap, dataId, callback) {
          // 当每个key都有值的时候触发校验
          let every = Object.keys(fieldMap).every(key => (fieldMap[key] !== null && fieldMap[key] !== undefined));
          if (every) {
            let params = { tableName, fieldMap, dataId };
            postAction('/sys/duplicate/checkMany', params).then(res => {
              res['success'] ? callback() : callback(res['message']);
            }).catch(err => {
              callback(err.message || err);
            });
          } else {
            callback();
          }
        }
        
      2. 组件调用

        // template     
        <a-form-model ref="form"  v-bind="layout"  :model="model" :rules="validatorRules">
                <a-form-model-item label="角色编码" required prop="roleCode">
                  <a-input v-model="model.roleCode" :disabled="roleDisabled"  placeholder="请输入角色编码"/>
                </a-form-model-item>
                <a-form-model-item label="角色名称" required prop="roleName">
                  <a-input v-model="model.roleName" placeholder="请输入角色名称"/>
                </a-form-model-item>
                <a-form-model-item label="描述" prop="description">
                  <a-textarea :rows="5" v-model="model.description" placeholder="请输入角色描述"/>
                </a-form-model-item>
              </a-form-model>
        ​
        ​
        // script
            validatorRules:{
                  roleName: [
                    { required: true, message: '请输入角色名称!' },
                {
                    validator: (rule, value, callback) => validateDuplicateManyValue("sys_role", {                  "role_name": value,"role_code":this.model.roleCode }, this.model.id, callback),
                    trigger: "blur",
                  },
                  ],
                  roleCode: [
                    { required: true, message: '请输入角色名称!'},
                  ],
                },
        
  2. 后端验证

    前端参数封装完了之后,开始后端接收进行验证

    1. 安全校验

      public static void specialFilterContent(String value) {
              String specialXssStr = " exec | insert | select | delete | update | drop | count | chr | mid | master | truncate | char | declare |;|+|";
              String[] xssArr = specialXssStr.split("\|");
              if (value == null || "".equals(value)) {
                  return;
              }
              // 统一转为小写
              value = value.toLowerCase();
              for (String s : xssArr) {
                  if (value.contains(s) || value.startsWith(s.trim())) {
                      log.error("请注意,存在SQL注入关键词---> {}", s);
                      log.error("请注意,值可能存在SQL注入风险!---> {}", value);
                      throw new RuntimeException("请注意,值可能存在SQL注入风险!--->" + value);
                  }
              }
          }
      
    2. 判断是新增验证还是编辑验证

      if (StringUtils.isNotBlank(duplicateCheckManyVo.getDataId())) {
                  // [2].编辑页面校验
                  num = sysDictMapper.duplicateCheckManyCountSql(duplicateCheckManyVo);
              } else {
                  // [1].添加页面校验
                  num = sysDictMapper.duplicateCheckManyCountSqlNoDataId(duplicateCheckManyVo);
              }
      
    3. sql注入

      1. 新增验证SQL

          <select id="duplicateCheckManyCountSqlNoDataId" resultType="java.lang.Long">
                SELECT COUNT(*) FROM ${tableName}
                WHERE 1=1
                <if test="fieldMap!= null">
                    <foreach collection="fieldMap.entrySet()" item="value" index="key">
                        <if test="value != null">
                            and ${key} = #{value}
                        </if>
                    </foreach>
                </if>
            </select>
        
      2. 编辑验证SQL

         <select id="duplicateCheckManyCountSql" resultType="java.lang.Long">
                SELECT COUNT(*) FROM ${tableName} WHERE id &lt;&gt; #{dataId}
                <if test="fieldMap!= null">
                    <foreach collection="fieldMap.entrySet()" item="value" index="key">
                        <if test="value == null">
                            and ${key} is null
                        </if>
                        <if test="value != null">
                            and ${key} = #{value}
                        </if>
                    </foreach>
                </if>
            </select>
        

        效果展示

        1. 页面数据

          image-20211029112659119

        2. 表单数据

          在新增表单里输入和之前数据一样的数据

          image-20211029112832258

        题外话

        1. react版的ant-design库与vue版的ant-design-vue库对比:

          • 在上面的react的例子中,我没有加必填校验的message字段,react却可以根据label值拼接出来”请输入角色编码“的提示信息。而vue版只有”roleCode is required“的提示。

          • 两个UI库在设置表单验证时机的代码上也不一样,react是使用validateTrigger设置在Form.Item表标签上,而vue是使用trigger设置rules规则里。

        2. 需要发起请求的校验一定要限制请求时机的,因为是onChange就执行请求,这会使得这个唯一校验的接口压力很大。原本是希望给这个接口加上防抖的,但是加上防抖之后,自定义校验不生效,所以这里只能退而求其次把校验时机从onChange改为了onBlur。(如果知道怎么在自定义校验里加防抖的童鞋,望告知,谢谢)

        3. 最后说明以上的校验思路是参考JEECG框架提供的单字段校验模式,来完成的多字段校验。