<< 返回博客
·4 分钟阅读

开发日志:从单据审批系统到 RSA 混合加密,一个月的技术演进

闪仓WMS团队一个月内完成了16种单据的审批/驳回系统、RSA 2048 + AES-GCM混合加密链路、以及带自动回滚的部署脚本,本文记录这些功能的技术决策与实现细节。

开发日志:从单据审批系统到 RSA 混合加密,一个月的技术演进

背景

闪仓WMS后端是一个基于 Spring Boot 2.7 + MyBatis Plus 的多模块 Maven 项目,运行在 JDK 1.8 上。在过去一个月里,我们集中交付了三个关键功能:16种单据类型的审批/驳回系统、RSA + AES-GCM混合加密传输链路、以及一套带自动回滚的部署脚本。本文记录每个功能背后的技术决策。

一、16种单据的审批与驳回

问题

闪仓支持16种单据类型,覆盖采购(询价单、订单、入库单、退货单、换货单)、委托(出入库单、退货单)、销售(报价单、订单、出库单、退货单、换货单)、仓库调拨、以及其他出入库。每种单据创建后都需要走审批流程,且不同类型审批通过后的库存操作完全不同。

状态机设计

我们定义了三个状态:

public enum BillOfDocumentStatus {
    WAITING_APPROVAL("待审批"),
    APPROVED("已审批"),
    REJECTED("已驳回");
}

状态流转规则:待审批可以转为已审批或已驳回;已审批后不可驳回;已驳回后不可再审批。这是一个不可逆的三态机。

审批触发的业务逻辑

AuditingBillOfDocument 方法标记了 @Transactional,内部根据单据类型执行不同的库存操作:

  • 采购入库单:增加商品库存,更新供应商应付账款,并根据 purchase_stock_approval_enabled 配置决定是否自动过审待入库商品
  • 销售出库单:减少商品库存,更新客户应收账款
  • 销售订单:计算员工KPI绩效
  • 销售退货单:扣除员工KPI,同时增加库存
  • 采购退货单:减少库存
  • 换货单(采购/销售):根据 isWarehouseExit 标记拆分为出库和入库两部分分别执行
  • 仓库调拨单:从源仓库减少库存,在目标仓库创建或增加库存记录

这里的一个设计决策是:审批和驳回的API端点使用 @RequestBody 接收JSON,而非 @RequestParam。这是为了配合RSA加密 -- 加密后的请求体必须作为整体传输。

入库审批开关

对于采购入库单,我们增加了一个租户级配置 purchase_stock_approval_enabled,存储在 goods_config 表中。当关闭时,入库单审批通过后自动将待检商品过审入库;当开启时,需要手动逐一审批入库。这个开关通过原生JDBC查询,避免引入额外的 MyBatis Plus 依赖:

private boolean isPurchaseStockApprovalEnabled(Integer bindingUserId) {
    try (Connection conn = dataSource.getConnection();
         PreparedStatement ps = conn.prepareStatement(
            "SELECT purchase_stock_approval_enabled FROM goods_config WHERE binding_user_id = ? LIMIT 1")) {
        ps.setString(1, String.valueOf(bindingUserId));
        ResultSet rs = ps.executeQuery();
        if (rs.next()) {
            return rs.getBoolean("purchase_stock_approval_enabled");
        }
    } catch (Exception e) {
        log.warn("读取采购统计审批配置失败,使用默认值(需要手动审批): {}", e.getMessage());
    }
    return true; // 默认需要手动审批
}

二、RSA 2048 + AES-GCM 混合加密

为什么是混合加密

纯RSA有一个硬性限制:2048位RSA密钥最多只能加密245字节的数据。一个包含十几件商品的单据JSON体可以轻松超过几KB。因此我们采用混合方案:

  1. 前端生成随机AES密钥和IV
  2. 用AES-GCM加密实际数据(无大小限制)
  3. 用RSA公钥加密AES密钥
  4. {ek, iv, ct} 三元组发送到后端

Filter Chain 设计

加密请求需要在Spring解析 Content-Type 之前完成解密。我们设计了一个三级过滤器链:

CachedBodyFilter (order=1) → RSADecryptionFilter (order=2) → LogInterceptor

CachedBodyFilter 将POST/PUT/PATCH请求体缓存到 CachedBodyHttpServletRequest,使请求体可以被多次读取。它跳过 multipart 文件上传请求,避免将大文件载入内存。

RSADecryptionFilter 检查 X-Encryption-Enabled: true 请求头。如果存在,它读取请求体,判断是纯RSA加密还是混合加密(通过检测JSON中是否存在 ek/iv/ct 字段),执行解密,然后用一个自定义的 HttpServletRequestWrapper 替换原始请求,将 Content-Type 修改为 application/json;charset=UTF-8

if (root.has("ek") && root.has("iv") && root.has("ct")) {
    String keyBase64 = RSAUtil.decryptFromFrontend(ek);
    byte[] keyBytes = Base64.getDecoder().decode(keyBase64);
    byte[] ivBytes = Base64.getDecoder().decode(ivStr);
    byte[] ctBytes = Base64.getDecoder().decode(ctStr);
    decryptedData = AESUtil.decryptGCM(keyBytes, ivBytes, ctBytes);
}

ResponseEncryptionAdvice 作为 @ControllerAdvice 在响应写出前添加 X-Response-Signature 头,使用 SHA256withRSA 签名响应体,供前端验证数据完整性。

向后兼容

加密是可选的。不带 X-Encryption-Enabled 头的请求直接通过过滤器链,行为与加密上线前完全一致。这让我们可以逐步迁移前端页面,而非一次性全量切换。

三、deploy.sh 自动回滚部署

五步部署流程

./deploy.sh              # 完整流程:打包 → 上传 → 重启 → 健康检查 → 集成测试
./deploy.sh --skip-test  # 紧急发布:跳过测试
./deploy.sh --test-only  # 仅运行测试,不部署

脚本的核心是第5步:部署完成后自动运行15个测试套件(103个测试用例),如果任何测试失败,立即触发回滚。

回滚机制很简单:部署前将当前JAR备份为 backup_sys.jar,回滚时将备份复制回来并重启。健康检查通过 curl 轮询 /config/version/latest 端点,等待HTTP 200。

STATUS=$(ssh "$USER@$SERVER" "curl -s -o /dev/null -w '%{http_code}' http://localhost:10086/config/version/latest")
if [ "$STATUS" != "200" ]; then
    rollback
    exit 1
fi

这套流程在过去一个月中阻止了两次有问题的部署自动上线。

四、集成测试覆盖

审批和驳回功能有专门的测试套件 10-audit-reject.test.js,覆盖以下场景:

  • 创建单据后审批,验证状态变更
  • 重复审批的幂等性处理
  • 创建单据后驳回,验证状态变更
  • 搜索单据验证状态正确性

测试使用 jsonPost 发送JSON请求体(模拟加密端点的请求格式),而非传统的 formPost

总结

这一个月的工作核心是让单据流转变得安全且可靠。审批系统确保库存变更只在显式批准后发生,混合加密保护传输中的业务数据,部署脚本保证错误的代码不会留在生产环境。三个功能互相配合,构成了一个完整的安全链路。