订单编号返回了,申请明细真的写对了吗

两份纸品和三支笔应该合计 13.75。采购 HTTP 入口若只返回一个申请 ID,仍有几种可能:金额在数据库被截断、明细没入库、租户值写错,或者状态更新找不到原行。检查这些问题不能只读插入方法的返回值。第 12 篇处理连接和语句的归还;这一篇沿着 examples/javaee-enterprise/adapters-jdbc/src/main/java/blog/javaee/jdbc/JdbcRequestStore.java 检查参数、返回行和对象重建。冻结环境为 Java 21、pgJDBC 42.7.7、PostgreSQL 16.15 与通过 jdbc/Procurement 提供连接的 Jakarta EE 11 服务器。

SQL 结构固定,数据从参数槽进入

JdbcRequestStore.insert 的申请 SQL 把 tenant_id、status、total 放入 ? 槽,通过 setString 与 setBigDecimal 绑定;find 的 WHERE id=? AND tenant_id=? 绑定申请 ID 和租户;transition 同时绑定目标状态、原状态与原版本。这样的代码不需要把租户值拼进 SQL 文本,能把数据与 SQL 语法分开。但“使用参数化语句”不自动实现授权:LabRequestsResource 的租户来自调用方填写的 X-Lab-Tenant,它只是隔离教学输入,任何调用者都能伪造。参数化防止该值改变 SQL 语法,不能证明这个租户身份可信。

PreparedStatement 的绑定还依赖数据库类型和空值约定。现有 001-initial.sql 把 tenant_id、status、total、sku、unit_price、quantity 均声明 NOT NULL,所以当前申请表没有可用于演示业务 NULL 的字段。要验证真正的可选值映射,应先明确新增字段的空值语义和迁移,然后分别测试 setNull(index, sqlType) 与 ResultSet.getObject 或 getXxx 后的 wasNull();不能声称目前的 getString() 已通过了可空时间字段测试。Java 21 setObject 可以绑定更多类型,但仍须针对驱动与 SQL 列的契约做集成测试,尤其是时间的时区及精度;当前 schema 没有时间列,也没有此类 PASS。

空值还有一个容易忽略的 JDBC 细节:getInt()、getLong() 对 SQL NULL 可给出 Java 基本类型的零值,必须随即查询 wasNull() 才能区分真正的 0 与空;读取成可空包装类型则应明确驱动返回值与列 SQL 类型。现在 quantity、id、version 等列约束非空,JdbcRequestStore 使用基本类型的读取不会在这些列上遇到 SQL NULL;若未来在审批意见或可选时间上照搬此模式,原来不出错的读取方法可能悄悄改变业务意义。同理,不要把 Java 的 null 与空字符串、没有这条行记录或未知商品混成一个“缺数据”的 HTTP 错误。先画出输入字段、SQL 类型、允许空值、Java 读取类型、数据库约束的对照表,再设计请求和回读断言。

时间字段尚未出现在初始 schema 里。若新版本加入 submitted_at,需要先说清它是某个地区的墙上时间,还是表示同一瞬间的时间戳;PostgreSQL timestamp without time zone 与 timestamptz 的比较、会话时区和 pgJDBC 的 Java 映射都可能影响结果。测试至少包括会话时区不同但同一瞬间的读回对照,以及夏令时重叠或缺失的本地时间;绝不能从当前 BigDecimal round-trip 成功外推“时间映射已验证”。这部分只有需求和测试合同,新增列、迁移、API 输入与容器运行均 NOT_RUN。

金额与行结果由两道约束守住

领域层的 LineItem 通过 setScale(2, UNNECESSARY) 拒绝需要非零第三位小数的价格,允许数值不变的 3.500;ProcurementRequest 重算总额并拒绝超过 numeric(18,2) 精度的值。数据库脚本 db/migrations/001-initial.sql 还对总额与单价规定 numeric(18,2)、非负约束及必要的非空约束。领域检查与数据库约束分别面对经用例构造的对象、以及绕过用例的 SQL;既不能靠 BigDecimal 类型名假定金额正确,也不能把数据库约束误称为“服务器已按固定商品目录定价”。目录单价在 application/src/main/java/blog/javaee/application/FixedPriceCatalog.java,目前 paper 为 3.50、pen 为 2.25。

numeric(18,2) 的限制还适用于数量乘积与总额,不能只检查每件商品的单价。例如价格自身未超限,数量扩大后 line.amount() 可以大于数据库容许总额;当前 ProcurementRequest 构造器会检查汇总结果的精度,数据库的列类型仍是另一道防线。对“精度 18”的理解也不能简化成“小数点前固定 18 位”:标度占两位,可表示范围与保存小数位需要一起计算。数据库如果因精度或约束拒绝一条直接 SQL,并不说明通过用例的 draft 一定会走到相同的异常分支;应分别记录领域拒绝、JDBC SQLException、事务回滚和另一连接最终可见行的判据。

读取时 find 先按 (id, tenant_id) 取得申请行,再以 request_id=? ORDER BY id 取得明细,把 status 交给 RequestState.valueOf,把 total 和 unit_price 用 getBigDecimal 读回领域记录。这个映射不是数据库自动完成的:列顺序改动、非法状态值、丢失明细均可能使构造失败。ResultSet.next() 为 false 时抛 RequestMissingException;它表示该租户条件下没有这张申请,不能据此判断申请在全库不存在,更不能回退到只用 ID 的查询。明细归属依赖 purchase_line.request_id 外键和当前按已核验申请 ID 的读取路径,正式读接口仍需可信身份。

insert 使用 PostgreSQL 的 INSERT ... RETURNING id 从结果行取申请主键,随后在同一方法里批量插入明细。executeBatch() 返回值、每行是否写入以及冲突后的事务状态需在目标驱动/数据库上核对;现有方法没有逐项检查 updateCounts,也没有通过 JDBC getGeneratedKeys()。因此“生成主键”是 PostgreSQL RETURNING 路径,不应引用 getGeneratedKeys 已验证。订单 SQL 的 ON CONFLICT(request_id) DO NOTHING RETURNING id 在冲突时改按租户查已有订单,唯一键保护的是每申请一张订单;并不保证通知或其它副作用恰好一次。

具体执行顺序决定失败归属:申请主键来自数据库 RETURNING,行实体要依赖它写入;若批处理明细失败,仅有主键曾被分配不代表申请最终提交。PostgreSQL 的 identity 序列即使事务回滚也可能留下编号空洞,编号连续性不是不变量;另一次独立的受管 draftThenAbortForLab 故障记录正是用序列推进与已提交行数零作对照。当前 JdbcRequestStore.insert 没有检查每一项 batch 更新计数,业务侧也不会向 HTTP 返回明细插入的逐条状态,正常脚本最终 ORDERED|13.75|1 不足以证明每一条明细的数量、SKU、单价与顺序全都正确。应增设从独立连接按 request_id、ORDER BY id 读取完整两行并对照 paper|3.50|2 与 pen|2.25|3 的断言,该补充场景 NOT_RUN。

把成功与失败限定在可读到的证据

已执行的顺序场景与待跑的约束反例分别列在SQL 参数及映射实验卡,数据库失败输出不能冒充 JDBC 受管入口的失败输出。

隔离数据库的一次真实顺序运行来自 examples/javaee-enterprise/scenarios/09-lab-procurement.sh:POST 两种商品,先检查未批准下单返回 409,再提交、批准、下单与重试,最终以独立 PostgreSQL 连接读到 ORDERED|13.75|1。原始输出在 writing-plans/javaee-enterprise/verification/20261004T062900Z-pg16-business/scenario.stdout.txt,退出码另存。该场景说明已部署的那个版本沿现有用例与 SQL 路径得到了目标状态、金额和订单数;脚本没有单独列出两行明细、NULL 值、时间戳或每项 JDBC batch 更新计数,也没有 SQL 注入负向原始报告。

现有正常路径可以这样复跑,前提是只对 README 要求的回环地址、独立 javaee_lab、专用角色运行,JAVAEE_LAB_PASSWORD 等在本地提供,不写进证据正文:

1
2
3
cd examples/javaee-enterprise
JAVAEE_PORT=9085 JAVAEE_DEMO_MODE=true JAVAEE_LAB_USER=javaee_lab \
bash scenarios/09-lab-procurement.sh

这个脚本还要求 JAVAEE_LAB_PASSWORD 环境变量,并要求正在运行的服务端也在隔离启动时设过 JAVAEE_DEMO_MODE=true;命令的退出码、租户与申请 ID、各次 HTTP 状态、独立连接的最终三元组应一起保存。脚本只断言第一次订单与重试得到相同 ID,并未保留逐行明细文本;要核对本篇映射,可根据它输出的随机租户/申请 ID 另在独立连接运行 SELECT sku, unit_price, quantity FROM purchase_line WHERE request_id=<这次申请ID> ORDER BY id。尖括号是需由操作者校验为数字后填写的占位符,不是一条可以原样粘贴执行的 SQL;补充查询与判据还没有运行记录,不标 PASS。

本章失败实验标记 NOT_RUN,可以从约束着手:仅在新建的隔离库中,先记录迁移 SHA、连接账号与现有测试租户行数,以独立 psql 尝试写入 total=-0.01,预期数据库拒绝 CHECK (total >= 0);再试 tenant_id=NULL,预期拒绝 NOT NULL。保存 SQLState、原始 stderr、命令退出码,并复查该租户没有新增行。这是计划,不是 JDBC 适配器已运行的故障验证;若要验证 Java 的 SQLException 到用例错误映射,必须通过受控 JDBC 测试入口执行相同非法输入并跟踪异常链,该入口尚未实现。SQL 注入也需真实从不可信输入抵达参数绑定处的反例,而不是只看到 ? 就填 PASS;身份认证与授权属于另一层。

为了不依赖不存在的 Web 失败接口,第一步只验证真实 DDL 对非法写入的响应;如下 psql 命令仅可用于已经按 001 脚本建立的隔离数据库,两条故意失败的 INSERT 不写任何生产数据,执行前仍须确认 current_database() 是 javaee_lab:

1
2
3
4
5
6
PGPASSWORD="$JAVAEE_LAB_PASSWORD" psql -X -v ON_ERROR_STOP=1 -h 127.0.0.1 \
-U javaee_lab -d javaee_lab \
-c "INSERT INTO purchase_request(tenant_id,version,status,total) VALUES ('lab-invalid-total',0,'DRAFT',-0.01)"
PGPASSWORD="$JAVAEE_LAB_PASSWORD" psql -X -v ON_ERROR_STOP=1 -h 127.0.0.1 \
-U javaee_lab -d javaee_lab \
-c "INSERT INTO purchase_request(tenant_id,version,status,total) VALUES (NULL,0,'DRAFT',1.00)"

两条命令的期望均是非零退出,分别在约束检查和非空检查处被拒绝;必须保存各自原始退出码、报错与查询后零新增行,不能用 Shell 的 ! 反转退出码后写成“psql 成功”。这一组只校验 PostgreSQL DDL,不经过 JdbcRequestStore,因此对 Java 层 SQLState 包装、关闭抑制异常、回滚边界或输入中的 SQL 注入都不构成验收。把非法 SKU 字符串送入 Web 的演示接口可以验证目录拒绝,但它在到达 JDBC 之前就会失败,同样不能冒充 JDBC 参数注入测试。

数据库与 Java 异常的对照需要另走一道受管路径。先保存发生异常的请求 ID、SQLState、约束名称和原始 SQLException;再检查 JdbcRequestStore 是否只把它作为 IllegalStateException 的 cause 抛出,有没有丢失明细批处理中的原始异常。受管事务如果已经看到错误,第二条 SQL 能否继续执行还取决于 PostgreSQL 当前事务状态,不能在同一已失败的事务里再次执行 SELECT 就断言没有写入。应在事务结束后使用独立连接查询随机实验租户的申请、明细与订单行数,且与正常场景的对照数据分开保存。没有可触发这种数据库拒绝的受管诊断入口时,只能说数据库 CHECK 与 NOT NULL 预计会拒绝输入,不能说用例的异常转换已被覆盖。

独立连接回读也需要明确比较单位。订单唯一约束按申请 ID 生效,订单表的租户列并不由外键自动对齐父申请;对请求按 (id,tenant_id) 过滤并查询订单时,应同时检查订单归属,不能只用 COUNT(*)=1 判断同一租户的数据全正确。申请的两条明细既可以逐行核对价格与数量,也要重新计算行金额之和,与申请 total 的 BigDecimal 数值进行比较。数据库精度允许的读回标度未必等于客户端输入字符串的原始标度,因此判据针对金额数值与既定的两位规范化结果,而不是字符串的表面形式。回读发生在已提交事务之后,才能为这次请求留下最终行集合。

批量更新的结果也要设失败判据:如果某条明细违反数量正数约束,驱动可能通过 BatchUpdateException 报告已尝试操作的更新计数;各条 SQL 是否已经执行及提交,仍要看当前事务最终如何结束。不能仅检查 executeBatch() 没抛异常就断言明细行数与输入数量一致,也不能将异常里某个更新计数当成“申请已部分提交”。现有调用没有暴露批处理逐项结果,只有申请/明细最终行的独立回读才能回答业务层问题。若未来为效率分批处理大量申请,必须记录每个批次的主键、输入数量和失败批次,避免把不同申请的结果凑成一次成功。

金额反例还应区分“目录输入错”与“持久化类型错”。客户端如果提交不存在的 SKU,固定目录在构造 LineItem 前便抛错,数据库不会收到它的单价;客户端附带自选 total,当前实验资源的 CreateInput 没有该字段,不能按它的内容核价。另一个反例是已存在的合法申请在明细 SQL 中被直接篡改价格:当前申请表的总额与明细和没有跨表数据库约束,重建对象时 ProcurementRequest 会重新比对金额并抛异常,但单纯 SQL 读表可能仍得到那两份不一致的数据。因此端到端校验必须分别看输入是否进入目录、SQL 实际保存值、读回对象校验和数据库表约束,不能把其中一个成功结果扩写成“所有金额路径均安全”。这些绕过用例的篡改路径未执行,不应对共享数据库尝试。

两道练习与答案

练习一:setBigDecimal 写 19.905 与 3.500,现有代码会走到数据库吗?如果直接跳过领域层把它们写进 numeric(18,2),能把数据库默认行为当成计价规则吗?

答案:经过 LineItem 构造的 19.905 因 UNNECESSARY 检查而被拒绝;3.500 可以数值无损地规范成两位。绕过领域层时数据库的约束和转换规则另行生效,不能把可能的舍入/报错当成目录定价政策。要证明存储值应按隔离库真实回读而非推断驱动做了什么。

这个回答还依赖单价来源:FixedPriceCatalog 返回的固定价格目前都符合两位精度;练习的两个任意输入并不意味着当前受管草稿接口允许客户提供单价。若用直接 SQL 绕开领域层测试小数位行为,需单列为数据库类型实验,不得再从它推断目录验价、HTTP 拒绝码或事务边界。

练习二:按一个申请 ID 查询得不到行,有人建议删掉 tenant_id 条件以“帮助调试”,并说参数绑定已经防止了安全问题。该建议错在哪?

答案:参数绑定仅阻止输入变成 SQL 语法,删掉租户谓词会让调用方取得别的租户的申请。正确诊断在隔离环境检查传入租户、真实身份来源和数据归属,保持租户条件;“未找到”对外也不泄漏目标是否存在于其它租户。现有演示头不具备身份可信性,跨租户 404 只证明给定参数下谓词奏效。

边界与资料

已记录的是一次顺序业务场景及基础构建,并非本章所有类型/失败矩阵 PASS。后续补测试要保存代码及 WAR 摘要、迁移版本、输入、SQLState、数据库终态和恢复步骤。规范与数据库入口:Java SE 21 PreparedStatement、ResultSet、SQLException、PostgreSQL 16 numeric、约束、INSERT RETURNING。它们分别描述接口与数据库行为,不代替本工程尚未运行的负向场景。