JPA 持久化 09:采购审批综合应用
采购审批综合应用:从一次合法调用到可恢复的终态
采购申请的页面显示“已下单”,数据库却没有订单,这不一定是 ORM 映射错误。状态改变、订单插入、事务提交、调用方收到响应是四个不同的时刻。要回答“审批成功后重试会不会多下一单”,不能只看 persist() 是否返回;还要回答同一租户以外能否读到申请,两次并发提交谁输,以及事务失败后是否有人仍拿着旧的 EntityManager 继续做事。本篇把前面 00–08 篇的实体、映射、查询和事务放进同一条采购链路,并严格区分代码已有的断言与尚未做的集成实验。
本篇源码、迁移、脚本与测试可从固定版本完整归档取得;版本 1b08ada,SHA-256 见源码清单。
示例冻结为 Java 21、Jakarta Persistence 3.2、Hibernate ORM 7.1.36.Final、pgJDBC 42.7.7、PostgreSQL 16;入口为 工程说明、业务实现、测试源码 和 实验说明。已有代码是最小实验工程,不是可直接上线的采购服务:它没有 HTTP 身份认证、幂等请求表、跨行预算锁或完整异常分类。以下的“预期”不是运行记录。
一个订单为什么牵涉三个边界
把 2026 年 10 月 4 日的一笔办公耗材申请具体化:租户甲采购文件夹两盒,每盒 12.50 元,另有纸张三包,每包 7.20 元;服务端计算总额 46.60 元。采购员先保存草稿,再提交;审批员批准后才允许建订单。同租户的采购员重新发起下单,应该得到既有订单;租户乙即使猜到申请 ID,也不该借此查询订单。金额不是页面传入的 total,而是逐行价格乘数量之和;拒绝申请永远不能转换成订单。
工程里的 ProcurementRequest 只允许 DRAFT → SUBMITTED → APPROVED → ORDERED 或 SUBMITTED → REJECTED;addLine 只在草稿状态执行,拒绝负单价、非正数量,累加 BigDecimal。ProcurementOrder 构造器也要求 APPROVED。但类内检查并不替代持久化约束:用户绕过服务直接执行 SQL、程序升级改变状态路径、并发事务在各自上下文中都看到旧状态,都需要另一个边界兜底。迁移脚本的 purchase_order.request_id UNIQUE 是“一申请最多一单”的数据库底线;它不负责鉴权,也不负责把失败的事务自动变成成功响应。
在 Jakarta Persistence 3.2 §3.3–3.4 中,托管实体的修改在工作单元里通过同步与提交写出;flush() 不是 commit()。§3.5 的实体版本规则保护带 @Version 的申请行,但不覆盖另一个申请行、订单唯一键或外部消息。PostgreSQL 16 的 UNIQUE 是数据库约束,不能因 Hibernate 正好抛出了某个包装异常,就把异常类型写成 JPA 跨 provider 的协议。
沿现有代码走一次正常路径
金额精度有一条容易遗漏的跨列约束。若单价 19.905 与 0.005 在 Java 中先相加、再各自写入 numeric(18,2),请求总额与数据库中两条明细的求和可能不同。独立审校后,addLine 在明细和总额计算前使用 setScale(2, UNNECESSARY) 拒绝需要舍入的单价,并检查 numeric(18,2) 范围。新增 PostgreSQL 测试在持久化前拒绝上述两项输入,另保存 19.90 与 0.01 两行,在新上下文重读后比较总额与行金额之和。原六项回归与这条新增断言合计 7/7,原始输出和逐文件 SHA 见 writing-plans/jpa/verification/20261004T061327Z-pg16-precision-guard/RUN.md;第三方直接写 SQL 的跨列一致性仍需额外约束或受控入口,本实验没有证明。
已有实现的真实调用入口在 ProcurementJpaTest.uniqueOrderAndTenantAndRetry()。测试先使用 DatabaseSupport.submitted(manager, tenant) 建立一个带一条明细的 SUBMITTED 申请:单价 3.50 元、数量 2,服务端计算 7.00 元;随后在同一 EntityManager 的新事务里 approve(),再在另一工作单元里执行以下关键调用(摘自现有实现,类及 import 以链接中的源文件为准):
1 | |
findForTenant 先按主键 find,再检查租户 ID,不匹配时抛出同一种“找不到”异常;测试断言异租户调用被拒。同一事务内第二次 order() 进入 ORDERED 分支,返回刚持久化的订单;测试断言两个对象的 ID 一样。提交后另开工作单元重试,最后查询这个申请的订单条数,断言等于 1。这三个断言分别检查权限入口、同一上下文内的重试、提交后的重试,不要把它们笼统合称“并发幂等已证明”。
还有一个时序上的细节:GenerationType.IDENTITY 可能使插入提前发生以取得 ID,但即便 id() 已有值,调用方在提交前也不能对外宣称订单成立;后续 commit() 仍可能因数据库约束、版本冲突或连接错误失败。一个正常的服务边界应该在事务提交成功后才确认响应。用户端超时后重试时,服务必须用服务端保存的状态和订单唯一键辨认终态,而不是根据客户端有没有收到上一次响应推断是否已插入。
[PATTERN] 同一业务操作同时需要领域状态机、工作单元与数据库约束:状态机拒绝非法顺序,事务让状态与订单同生共死,唯一键兜住并发插入。三者解决的是不同问题,缺一项都不能用另两项“自动补齐”。
租户检查不是一个可省略的 WHERE
现有代码的 findForTenant 先 manager.find(ProcurementRequest.class, requestId),再检查 tenantId,所以它能拒绝已知 ID 的跨租户下单,却可能在授权判断前从数据库加载了目标实体。这个实现用于教学最小闭环,不宜据此声称已经实现数据库层行级安全:其他查询或对 ProcurementOrder 的直接访问仍需强制带租户条件;日志、二级缓存与异常信息也不能泄露异租户数据。尤其要保证调用方传入的租户 ID 来自经过认证的主体,而不是浏览器随意指定的字符串。
在真实服务里可用带 tenantId 的查询或数据库 RLS 做纵深防御,但两者都需要系统性覆盖所有读写入口及其测试。本例 purchase_order 有自己的 tenant_id 列,迁移脚本未建立跨表租户一致性约束;订单构造器从申请复制租户是一条应用层路径,而不是任意 SQL 写入都成立的数据库不变量。如果管理员要在租户内列订单,不能只按全局订单 ID 查出后直接序列化。测试时应分别从合法租户和不合法租户尝试读取同一申请及同一订单,观察授权结果和数据库行终态,不能仅凭一个抛错断言宣布“租户隔离覆盖完整”。
两次并发下单:把竞争点画清楚
设事务 T1、T2 各自拥有一个 EntityManager,都读取同一个 APPROVED 申请。二者先后各自构造一个订单并把本地实体置为 ORDERED;各自的 Java 对象互不可见。T1 提交成功后,T2 在刷新申请版本或插入相同 request_id 时必须面对冲突。哪一步先抛异常、具体被包装成什么类型,是 provider 的 SQL 顺序和数据库结果,不应预先臆测。应用层预期仅是:不能留下两单;若 T2 失败,则回滚并关闭或丢弃该工作单元,新建上下文重读申请与订单。如果 T1 已提交,T2 的上层可以把“查询到同一申请的既有订单”转换为重试成功;如果没有订单,就不能盲目报成功。
现有 optimisticConflictWithControlledInterleaving() 测的是同一申请的审批与驳回:先让两个上下文都读到 SUBMITTED,第一个批准并提交,第二个驳回并提交时预期失败;最后用新上下文断言状态 APPROVED。它不是两个事务同时插入订单的实验。uniqueOrderAndTenantAndRetry() 也没有创建竞争事务。需要新增专门的顺序屏障、两连接与终态断言,才能把“并发订单”标成已验证。
数据库约束失败后通常不能继续在同一事务里查订单;先回滚,销毁本次 EntityManager,再用新事务重读才有意义。不要捕获所有 PersistenceException 就统一重试:网络断开、约束冲突与授权失败不是一类故障;整笔事务重试还要规定次数、退避和外部副作用处理。如果提交结果对客户端不确定,优先读回稳定业务键对应的数据库终态,必要时再重试整个事务。
[PATTERN] 并发正确性的验收对象是提交后的数据库终态,不是哪一个线程先打印了日志。失败路径需要单独验证回滚、上下文销毁与重试后的读回。
事务失败发生在哪一层,恢复就从哪里开始
申请状态和订单处于同一 PostgreSQL 事务时,业务代码返回 ProcurementOrder 不代表写入已经完成。manager.persist(order) 先把对象纳入上下文;显式 flush() 可能发送插入和状态更新,仍允许事务回滚。只有 commit() 成功,才有提交后可观察的数据库终态。失败实验不能只在 persist 前主动抛异常,因为那样没测试到刷写后回滚;应在事务写入并 flush() 之后故意回滚,再用新上下文核对 purchase_request.status 仍是 APPROVED 且 purchase_order 没有该申请的行。反过来,也不能靠回滚后的原 Java 对象 status() 推断数据库值:对象属性不因数据库回滚自动恢复。
还有更难的窗口:数据库可能已经提交,但客户端在收到应答前断连。此时重试逻辑不能简单将“连接异常”解释成“一定没有写入”,也不能把异常包装成成功。第一步是在新事务里按认证租户与申请 ID 重读;读到 ORDERED 加一张订单,说明已有可返回结果;若读到 APPROVED 且无订单,才考虑重试原操作。若状态与订单数量不吻合,要报告不变量破坏,不应在未核查原因时补插。跨进程去重或对外发送采购单时,还需额外的幂等键与事务外副作用设计,本工程没有这部分实现。
本章还需区分“拒绝”与“失败”。审批员对 SUBMITTED 执行 reject() 是一个正常业务终态;对 REJECTED 申请调用 order() 才是非法状态失败。测试前者应提交并断言没有订单,测试后者要先在独立事务保存拒批结果,再开启下单事务捕获异常并回滚,最后由新上下文核对仍是 REJECTED、订单数为零。把两种情况合并为一次 assertThrows,既无法证明拒批已经保存,也无法证明失败时没有意外写入。
如何复现,什么已经看到了
复跑时先按工程 README.md 在专用 jpa_lab 库执行 迁移 SQL 并设置 JPA_LAB_JDBC_URL、JPA_LAB_USER、JPA_LAB_PASSWORD,再从仓库根目录运行 examples/hibernate-lab/mvnw -B -ntp -f "$PWD/examples/jpa/pom.xml" clean test。这是复跑命令,不是本次写作重新执行的记录;历史运行命令与退出码见 writing-plans/jpa/verification/20261004T053805Z-pg16-resource-local/RUN.md。不得将工程指向共享数据库。persistence.xml 的 hibernate.hbm2ddl.auto=validate 是 Hibernate 配置,不会自动迁移。
已有观察(PASS,范围受限)。 归档 RUN.md(证据路径:writing-plans/jpa/verification/20261004T053805Z-pg16-resource-local/RUN.md,需仓库权限) 与同目录 test.stdout.txt、exit-code.txt、code-sha256.txt 记载既有 PostgreSQL 16.15 RESOURCE_LOCAL 运行:Hibernate 7.1.36.Final,ProcurementJpaTest 共 6 项,失败 0、错误 0、跳过 0,命令退出 0。归档对当时未提交的工程文件逐文件给出 SHA-256,不应把基线 Git 提交号冒充工程 SHA。其中 uniqueOrderAndTenantAndRetry() 已验证异租户拒绝、同事务与提交后顺序重试及订单数为 1;金额回读、flush 后回滚和审批冲突另有断言。这里可以说这些具体测试路径 PASS,但不能把顺序重试称为并发幂等;本次写作没有重新运行数据库测试。
扩展正常场景(NOT_RUN)。 已通过的单明细 7.00 元顺序流程不等于本章完整验收:另用两个租户建含两条明细的草稿,核对金额 46.60,提交并批准,构造订单后在新上下文查询请求 ORDERED、恰好一张订单及租户一致;拒绝路径另外创建一份申请。记录每步事务边界、申请与订单 ID、版本、三张表的行和值,并保存命令、退出码、原始输出、代码 SHA 与版本。
扩展失败场景(NOT_RUN)。 现有测试覆盖异租户拒绝,但未覆盖拒批申请建单后的数据库终态;还需在两个连接上控制同时读取同一 APPROVED 申请、先后提交,检查至多一张订单;另在插入后、提交前制造事务失败,检查新上下文能读到原状态且没有孤儿订单。分别测并发重试、提交结果不确定时的读回、失败事务结束后的新上下文恢复。上述路径尚无专门的可筛选测试,不能据现有 6/6 宣称完整并发幂等 PASS。实验模板见 实验说明。
两道练习
练习一。 请求已在 T1 成功插入订单并提交,但 HTTP 响应丢失;T2 再次携同一租户与申请 ID 调 order()。能否直接返回 409,或者不检查数据库就再次插入?**解:**都不稳妥。T2 用新工作单元按授权租户找到申请;若已经 ORDERED,查询对应的订单并返回同一业务结果;若终态不一致(状态已下单但无订单),应报数据异常并人工或程序修复,不能凭状态伪造订单。不同请求号是否也代表同一业务动作,需要另定业务幂等键契约,当前工程只按申请 ID 识别订单。
练习二。 T1、T2 都读到 APPROVED,两个 ProcurementOrder 在 Java 层已经有 ID;可否认定最多只会有一个失败,所以失败者继续用原 EntityManager 查询赢家?解:“最多一单”由提交后数据库 UNIQUE(request_id) 及事务保证,与实体 ID 出现早晚无关。若 T2 的提交失败,应回滚并弃用本次上下文,再在新事务读回请求和订单;乐观版本检查也可能比唯一键先报冲突。若两个事务都失败,则无赢家,需要按原因处置,不能以“必有一个赢家”为前提返回成功。
边界与速查
| 要守住的条件 | 现有入口 | 还缺的证据或机制 |
|---|---|---|
| 服务端核算金额、限定状态 | ProcurementRequest.addLine/approve/markOrdered |
多明细、拒绝路径与数据库终态专测 |
| 同申请最多一单 | ProcurementService.order 与 UNIQUE(request_id) |
双事务同单竞争的可控交错和恢复 |
| 同租户重试 | findForTenant 与既有订单查询 |
认证主体、全查询覆盖、提交结果不确定的处理 |
| 审批冲突 | 申请 @Version |
与跨行预算、订单唯一约束分开验证 |
本章只覆盖单库本地 RESOURCE_LOCAL 工作单元。JTA 容器、XA、出站消息的 exactly-once、跨租户数据库级策略和并发预算预留都没有在工程中验证;它们不能由六项 JUnit 测试外推。PostgreSQL 16 的约束与隔离结论仅针对该数据库;Hibernate 7.1 的生成 SQL、异常包装和统计数据仅代表所用实现;Jakarta Persistence 3.2 规定的是实体与事务语义,不规定采购业务协议或对所有数据库的一致执行计划。
参考资料
- Jakarta Persistence 3.2 规范 §3.3–3.5(实体操作、事务、锁);Jakarta Persistence 3.2 API(
EntityManager、EntityTransaction、OptimisticLockException)。 - Hibernate ORM 7.1 User Guide(flush、locking、schema validation):仅用于解释本例实现。
- PostgreSQL 16:Unique Constraints、Transaction Isolation:只说明数据库约束与隔离,不代替应用授权。
- 累计工程入口、归档实验记录 RUN.md(证据路径:
writing-plans/jpa/verification/20261004T053805Z-pg16-resource-local/RUN.md,需仓库权限);实验说明。





