开发日志:从单据审批系统到 RSA 混合加密,一个月的技术演进
闪仓WMS团队一个月内完成了16种单据的审批/驳回系统、RSA 2048 + AES-GCM混合加密链路、以及带自动回滚的部署脚本,本文记录这些功能的技术决策与实现细节。
背景
闪仓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。因此我们采用混合方案:
- 前端生成随机AES密钥和IV
- 用AES-GCM加密实际数据(无大小限制)
- 用RSA公钥加密AES密钥
- 将
{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。
总结
这一个月的工作核心是让单据流转变得安全且可靠。审批系统确保库存变更只在显式批准后发生,混合加密保护传输中的业务数据,部署脚本保证错误的代码不会留在生产环境。三个功能互相配合,构成了一个完整的安全链路。