Java EE 企业应用 01:把采购审批需求写成可检查的规则
“审批通过”究竟改变了什么
某部门提出一笔采购:申请包含两份纸品与三支笔,提交人希望审批通过后生成订单。若只写“支持申请和审批”,开发者可以实现一条更新状态的接口,却无法回答客户端重试是否会生成两张订单、另一个部门能否审批、用户输入总额与服务器报价冲突时应采信哪个值。真正可检查的需求必须说明输入、操作者、前置状态、允许的变化和失败后留在数据库里的结果。第 00 章已有 Open Liberty 部署及健康接口 200 ready 的原始证据,只证实最小容器路由。本章定义采购规则并核对现有领域实现,不假装已经有认证用户的采购 HTTP 接口。
这里的“部门”抽象为租户,示例租户为 tenant-one 与 tenant-two,没有真实人员数据。采购由草稿、提交、批准、下单构成主路径;拒绝后终止。供应商选择、预算冻结、库存、支付、税务、财务记账与通知送达均不在当前规则集里;把这些词写进需求并不能令系统拥有相应能力。业务确定性先于组件选择:哪一层实现规则、哪一层持久化状态,才是在后续章节引入 CDI、JDBC 和 Jakarta Transactions 时需要回答的问题。
从用例到验收断言
用例一是创建草稿。申请人给出 SKU 和正整数数量,服务器按受控目录给每条明细定价,计算申请总额,赋予租户和 DRAFT 状态;创建成功后返回新申请标识。用例二是提交草稿:只有属于当前租户且状态为 DRAFT 的申请可变为 SUBMITTED。用例三是审批决策:提交态可以批准为 APPROVED 或拒绝为 REJECTED,不能在同一版本上二者同时成立。用例四是将已批准申请变为 ORDERED 并生成订单;同一申请最多一张订单。输入还需包含请求所依赖的对象标识和租户语境,不过调用方自己声称“我是某租户”并非身份凭据。
给每个用例写一个反例,就能避免“失败了抛异常即可”的含混结论。草稿已经提交,再次提交应拒绝并保持原版本;未提交的草稿不能直接批准;拒绝后不能生成订单。租户二请求租户一的申请必须既读不到也改不了;客户端修改单价或总额不能绕过服务器核价。还需观察申请状态与版本、订单行数、金额和租户归属。异常消息是诊断线索,不能代替最终数据库行和授权检查。现有 schema 不含审批记录表,不能把“没有重复审批记录”当作已验证断言。
在责任分工上,采购申请人可以创建和提交自己的草稿;审批人只能处理授权范围内已提交申请;订单操作员只能为批准的申请下单;审计只读角色仅看其授权范围内记录。这是一张待实现的权限矩阵,不是现有工程已经生效的授权系统。需要区分“属于同一租户”和“拥有某角色”,更需要区分“创建了这张申请”和“同部门任何人都可以改”:若制度要求仅创建人能提交,必须记录所有者并验证它;现有 ProcurementRequest 没有创建人字段,所以目前不能声称满足“只能提交自己的草稿”。
状态图只有五个节点,没有隐藏的取消入口
当前 RequestState.java 的允许边是 DRAFT → SUBMITTED、SUBMITTED → APPROVED、SUBMITTED → REJECTED、APPROVED → ORDERED。REJECTED 和 ORDERED 是终态;对当前状态调用 moveTo(next),任何不在允许边上的转换都抛 IllegalStateException。包括对相同状态“再提交一次”,也不是无条件成功的幂等状态更新。订单的重复调用例外由用例层按已有订单处理,不应把它错误地画成 ORDERED → ORDERED 的新状态边。
计划曾提出要把取消和重新提交的来源状态列清楚。此处的明确答案是:当前五态模型不允许任何取消或重新提交。没有 CANCELLED,不能假设草稿、提交态或已批准态都能取消;REJECTED 没有重提边,不能偷偷将它更新回 DRAFT。如需修改被拒申请,应另开需求讨论新申请还是重开原申请,并定义审计与版本语义,而不是在本章的验收表里虚构一条边。正常状态表与失败判据见规则矩阵及复跑卡,可直接拿来制定后续测试输入。
状态检查发生在哪里很重要。ProcurementRequest.java 的 transition 构造一个版本加一的新值,不修改原对象;它委托状态枚举判定合法性。领域层能拒绝“草稿直接批准”,却不认识“当前调用者是不是审批人”。ProcurementUseCases.java 调用 store.find(id, tenantId)、request.transition(next) 和 store.transition(request, next);这里对转换的调用用于检查是否合法,持久化动作仍以旧快照和目标状态作为参数。不能看见 @Transactional 就推断每个入口一定有真实事务;那要求对象通过容器管理入口调用并在真实部署下验证。
状态图拒绝非法跳转,数据库还要拒绝陈旧的合法跳转。当前 JdbcRequestStore.java 的更新条件同时包含 id、tenant_id、原 status 与原 version,更新零行即报告冲突。两个审批人读到同一版本,分别选择批准与拒绝时,最多一个版本条件可先更新成功;要证明实际事务、锁竞争与最终行仍须用真实数据库和受控交错做实验,不能用单线程领域测试推断并发结果。尤其是审批、建单若跨越多个 SQL,必须核查事务是否把状态与订单同时提交,不能仅凭版本字段宣称所有副作用已经原子化。
金额、租户和唯一订单分别靠什么约束
示例目录 FixedPriceCatalog.java 给 paper 标 3.50,pen 标 2.25。两份 paper 加三份 pen 应得 2 × 3.50 + 3 × 2.25 = 13.75,单位是教学中的同一种货币计价单位,不能无需求引入汇率或税率。用例层 draft 接收 SKU 与数量,向目录取价格,再创建明细;客户端即使附带 total=0.01,这个入口也不接受该字段作为定价依据。若商品目录未知 SKU,用例会遇到目录拒绝,而非把客户端价格当作后备值。
LineItem.java 使用 BigDecimal,要求 SKU 非空、数量至少一件、价格非负,并用 setScale(2, UNNECESSARY) 拒绝需要非零第三位的小数。它允许零价,也允许 3.500 这类无需改变数值的表示,不应误写成“单价必须大于零”“只允许输入恰好两位小数”。ProcurementRequest.java 用各行 unitPrice × quantity 求和、要求至少一行,检查传入总额与计算结果相等,精度上限与数据库的 numeric(18,2) 对齐。若大量合法明细之和越过精度上限,也应拒绝,不能写入截断后的金额;不为教学例子杜撰折扣、舍入或汇率策略。
这里有一道信任边界:领域对象的构造器只核对 total 是否等于明细之和,不检查单价来源。现有 ProcurementUseCases.draft 才通过 PriceCatalog 获取目录价格。任何绕开该用例直接构造领域对象或写数据库的路径,都不能借“服务端计算总额”宣称符合采购定价政策。这一区别决定以后开放 HTTP 接口时只允许客户传 SKU 和数量,并在受管入口完成身份、授权与核价,而不能直接绑定包含金额和租户的领域对象。
租户约束同样分层。001-initial.sql 为申请、订单记录 tenant_id,在 purchase_order.request_id 上设置唯一约束;JdbcRequestStore.java 按 (id, tenant_id) 读写申请,避免只凭一个 ID 返回别的租户的申请。然而调用者仍可自行传入任意 tenantId,库里也没有完整的角色与对象授权模型;现阶段只能说 SQL 有租户条件,不能说租户隔离已通过安全验收。需要受管身份映射的租户不可由请求任意指定,跨租户读、改、下单要分别做拒绝测试。
一张申请最多一张订单靠数据库唯一约束提供最后一道防线;用例层发现状态为 ORDERED 时返回已有订单 ID。JdbcRequestStore.insertOrder 使用 PostgreSQL ON CONFLICT(request_id) DO NOTHING RETURNING id,冲突时再按租户查已有订单。因而在同一申请重复下单这个受限情形下,预期是同一订单而非重复插入;这不等于所有提交或审批请求都有通用幂等键。更不能因为写了唯一约束就跳过“下单已成功但应答丢失”的重试与账本核对。若订单写入与状态转换不在同一有效事务,可能留下状态和订单不一致;已有插入申请与明细后的故障回滚证据不覆盖订单阶段、应答丢失和外部副作用。
把规则变成正常与失败用例
当前 ProcurementRulesTest.java 给出无需容器的最小入口:测试 3.50 × 2 = 7.00、草稿不能直接批准、主路径可走到 ORDERED、被拒申请不能下单、传入的总额不等于明细和会失败、19.905 作为单价会失败。该文件实际只有一个测试方法,包含多项断言;writing-plans/javaee-enterprise/verification/20261004T061900Z-pg16-foundation/build-3.stdout.txt 中 clean verify 的原始输出记录 Tests run: 1, Failures: 0, Errors: 0, Skipped: 0,并显示 WAR 打包成功。这是领域层所列正常与拒绝断言的一次通过,不包含本章两份纸品加三支笔的 13.75 用例,更不是订单、权限或受管事务的验收。本章扩展成可执行的规格时,可先在领域测试中按输入、预期状态或异常、版本增量逐项检查,再在数据库集成层核对表行及回滚,在容器层核对事务与权限,最后才测 HTTP 与通知。
最小正常故事使用两份纸品和三支笔创建 tenant-one 草稿,总额应为 13.75;提交后仅允许批准或拒绝二选一,选择批准后下单,最终应有状态 ORDERED 和仅一张订单。工程没有经过认证授权的采购 HTTP 入口;隔离实验中启用 JAVAEE_DEMO_MODE=true 后,scenarios/09-lab-procurement.sh 可顺序执行这条路径。后续运行记录 writing-plans/javaee-enterprise/verification/20261004T062900Z-pg16-business/ 中,脚本退出码为 0,独立数据库连接核对到 ORDERED|13.75|1,但演示接口的 X-Lab-Tenant 可以伪造,不能证明登录或租户授权;也不能用 scenarios/00-health.sh 的 ready 假装申请已提交。复跑仍须在隔离的 javaee_lab 迁移 schema,保存部署包摘要、完整命令、配置脱敏值与最终行集合。
失败故事至少要覆盖三种不同问题。重复审批:同一申请已从 SUBMITTED 改为 APPROVED,再次批准不得生成第二个有效决策;两个操作者拿到同一版本并发操作时须用屏障控制交错、检查最终版本与审批结果,而不是依赖 sleep。非法状态:DRAFT → APPROVED、REJECTED → ORDERED 必须抛出并保持原状态;只检查抛异常不检查数据库最终行,不能证明回滚。金额篡改:直接构造总额与行金额不等的对象须拒绝;如果未来 HTTP 接口收到伪造总额,服务端必须忽略或拒绝而不能采信,当前没有该 HTTP 接口,不得把设想写成已通过的负向 API 测试。
再加一项跨租户反例以明确安全边界:租户二拿租户一的申请 ID 发起读取、审批或建单,最终都不得读到或改变租户一的数据;要求用真正受管身份发起请求,而不是把传入参数从 tenant-one 换为 tenant-two 后就宣布授权通过。特别检查同一租户的普通申请人能否调用审批:当前没有认证、角色或创建人字段,规则表中的预期无法通过现有代码验证。具体可复跑输入、缺失证据及分层断言列在规则矩阵及复跑卡,表中不会填伪造的数据库行或服务器回包。
本章领域测试已通过;后续的隔离容器顺序采购场景也已观察到 ORDERED|13.75|1,另有独立的插入后故障回滚场景,分别见 writing-plans/javaee-enterprise/verification/20261004T062900Z-pg16-business/ 和 20261004T063400Z-pg16-jta-rollback/。它们对应各自当时的源码与部署物,不代表当前工作树或完整规格均已验收。可控并发审批、金额篡改负向 HTTP 请求、真实身份授权与跨租户越权仍为 NOT_RUN。第 00 章的健康响应不扩大这些断言范围;服务器启用安全特性也不等于用例已验证用户角色。证据缺口见规则矩阵及复跑卡。JPA 先行系列的领域概念可对照实体身份与工作单元,但其资源本地事务与这里的 JDBC、受管入口并非同一实验,JPA 测试结果不替代本章验收。
两道练习与答案
练习一:有人提出“拒绝后允许修改数量并重新提交”。只改数据库 status 为 DRAFT,能满足此需求吗?给出至少三个缺失的决定,并说明当前可执行结论。
答案:不能。当前 REJECTED 是终态,状态机无回边;还需规定由谁修改、沿用原审批记录还是重新开申请、目录单价与总额何时重新核定、旧版本和订单如何处理。未正式扩展状态图、数据模型、授权及审计前,对该操作应拒绝,不能用手工 SQL 作为“已支持重提”的证明。
练习二:同一申请从 APPROVED 下单时客户端因超时重试,两次调用都报告了订单 ID。怎样检查“没有重复订单”,还需避免哪种错误推论?
答案:在同一隔离库核对 purchase_order 以申请 ID 过滤恰有一行、两次返回指向同一订单,并核对申请最终状态为 ORDERED;同时保存请求和事务的时间线。唯一约束只证明这张表不会保存两个相同 request_id 的订单,不能证明其他副作用仅发生一次,也不能证明调用者通过身份授权或通知已送达。
版本化资料与未覆盖面
业务状态图是本系列的教学约定,不是 Jakarta 平台自动规定的采购流程。Jakarta EE Platform 11.0 给出应用使用的容器与规范边界;Java SE 21 BigDecimal 提供精度与 setScale 的 API 依据;PostgreSQL 16 数值类型 与 PostgreSQL 16 约束 对照 numeric(18,2) 和唯一约束。所列 GitLab master 源码路径对应当前工作树中已有文件;文件尚未推送或合并时远端链接可能无法打开,需发布后逐条核验。消息、完整角色授权、订单外部系统与生产级故障恢复不在本章交付范围。





