华夏ERP 是一个开源的国产ERP系统,支持进销存基础功能,扩展插件可以支持财务+生产等功能(需要付费)。之前二次开发的点可云ERP V6 版本也是没有生产模块,当时为了了解、开发 bom 等生产内容还去工厂待过一段时间。不过当前的项目进销存是已经够了,还有报表功能也是一个惊喜。

在根据业务端对华夏ERP 进行二次开发,主要是仓库管理模块修改之后,业务又提到现有一个 Excel 表单,希望能够支持导入到系统中。因为修改之后表单与表单详情、商品之间是一对一的关系,所以可以将详情的内容跟表单数据放在一起导入,在读取 Excel 数据时候,再拆分开来插入到数据库。

因为原业务 Excel 表单入库和出库是一条记录,所以在导入时要根据关键词判断。首先先插入入库记录,然后如果是关键词包含 出库 则再增加一条出库记录。同时在入库操作里增加商品信息判断处理。之前后台是先创建商品,或者选择现有商品关联到详情记录,再一起提交入库或者出库记录,现在需要在商品存在的时候获取商品唯一标识 bar_code 和商品ID material_id。如果商品不存在,则先创建商品再去创建关联的表单详情。

以上是修改后导入操作的基本逻辑,华夏ERP通过接入 [jxl:2.6.12][3](已经是最新版本了,2011 年以来都没有更新过) 来实现 Excel 的导入导出操作,一开始看网上的介绍 Java实现excel导出功能的几种方法——poi、easyExcel、easypoi、jxl,误以为 jxl 只支持导出 xls 文件,不支持导入,于是就转头接入了 easyexcel:3.3.3。等测试接入完成以后才从基础资料里看到有导入的按钮,正好把 modal 拿过来直接用。

测试导入 Excel 出现的类似 商品:null库存不足 等报错,在调整 bar_code 获取方式之后修复了。之后就是这个 WRONGTYPE Operation against a key holding the wrong kind of value 报错。翻译了一下,针对持有错误类型值的键的错误类型操作。没太懂什么意思,然后通过调试发现错误发生在 logService.insertLog() 插入用户操作日志的部分。

...
Long count = logMapperEx.getCountByIpAndDate(userId, moduleName, clientIp, createTime);
if(count > 0) {
    //如果某个用户某个IP在同1秒内连续操作两遍,此时需要删除该redis记录,使其退出,防止恶意攻击
    redisService.deleteObjectByUserAndIp(userId, clientIp);
}
...

如注释部分所说的,因为是批量插入操作,所以肯定是会有 某个用户某个IP在同1秒内连续操作两遍 的情况发生的。这也是为什么前面调试的时候,一行一行的去执行,到最后发现操作是成功的原因。因为调试时每次操作因为断点执行超过了 1s 导致并没有触发这个 删除该redis记录 的执行。

再进入到这个删除 redis 记录操作内:

    public void deleteObjectByUserAndIp(Long userId, String clientIp){
        Set<String> tokens = redisTemplate.keys("*");
        for(String token : tokens) {
            Object userIdValue = redisTemplate.opsForHash().get(token, "userId");
            Object clientIpValue = redisTemplate.opsForHash().get(token, "clientIp");
            if(userIdValue!=null && clientIpValue!=null && userIdValue.equals(userId.toString()) && clientIpValue.equals(clientIp)) {
                redisTemplate.opsForHash().delete(token, "userId");
            }
        }
    }

通过断点和 redis 客户端发现,这里取了所有的 redis 键名遍历执行删除 #{token} -> userId。本地 redis 因为连接过其它的项目,所以存在其它的一些键值对,在获取 redisTemplate.opsForHash().get(token, "userId") 的时候就报错了。在清除了本地 redis 中的除 token 以外的键值对后,报了另一个错:java.lang.NullPointerException。错误轨迹指向了 userService.getCurrentUser()

Long userId = Long.parseLong(redisService.getObjectFromSessionByKey(request,"userId").toString());

调试时需要快速恢复程序执行多次,才能卡到这里。在 userId 被删除之后再次获取,此时为 null,执行 toString() 报错 java.lang.NullPointerException

到这其实问题已经很清楚了,只要短时间多次执行插入用户操作日志就会报错。即使清除了其它的 redis 键值对,或者这边修复这个空指针错误,也不能解决用户登录状态被清除的问题,后续插入肯定会失败的。直接的解决方法就是判断是批量操作,则不执行插入用户操作日志。

# Controller
request.setAttribute("batch", "1");

# service
if (request.getAttribute("batch") == null) {
...
}