quantity=1&quantity=5&confirmation=1 进入普通 Form 绑定后,当前实验取得 quantity=1,且没有验证错误。调换两个 quantity 的顺序,把 confirmation 改成5,绑定值也变成5。标量值已经被选择,后续验证器无法据此判断原始字段是否重复。

另一个问题发生在验证之后:两个输入完全有效的请求争抢一份库存,得到 201 与409。表单有效只说明当前输入满足约束;库存是否仍可扣减,由执行业务操作时的共享状态决定。

从请求体到业务结果

本篇使用 Play 3.0.6 的 FormFactory、Form 与 Bean Validation 集成。实验有三条不同入口:GET /content/form 生成带 CSRF token 的页面;POST 同一路径执行严格表单检查和预约;POST /content/form/ordinary 只观察普通绑定,不改变库存。

阶段 负责的问题 当前失败表达
urlencoded parser 媒体类型与请求字节 parser 拒绝
原始字段检查 未知字段、重复标量 400,UNKNOWN_FIELD / DUPLICATE_FIELD
类型绑定 quantity 能否转换成 Integer 400,FORM_INVALID
字段与类约束 范围、空白、跨字段相等 400,FORM_INVALID
库存操作 当前库存能否原子扣减 409,STOCK_CONFLICT

本例把绑定与约束失败汇入一个页面码,页面错误列表仍展示对应字段。对外错误结构可以合并,机制上的发生位置仍需要区分,否则会把库存冲突错误归到“输入不合法”。

绑定前保留多值信息

控制器先取得 Map<String, String[]>,检查字段名属于 name、quantity、confirmation,并要求每个标量数组只有一项。随后才执行:

1
2
3
4
5
6
7
8
Form<ReservationForm> bound = forms.form(ReservationForm.class)
.bindFromRequest(request, "name", "quantity", "confirmation");
if (bound.hasErrors()) {
List<String> errors = bound.errors().stream()
.map(error -> error.key() + ":" + error.message()).toList();
return badRequest(views.html.contentForm.render(
values, errors, "FORM_INVALID", request));
}

完整 imports、注入字段与路由在累计工程的 ContentController 中。allowedFields 限定绑定范围,明确的 UNKNOWN_FIELD 响应来自前面的字段名检查;不能把该参数描述为自动拒绝所有未知字段。

Form.fillDataWith对普通标量取索引0。以 [] 结尾的字段有索引展开逻辑;当前业务只允许三个标量,不采用数组字段表达,因此在信息折叠之前拒绝重复。

requestData还支持其他 body 表达。通用 Form API 的能力不等于本接口全部接受:控制器显式指定 FormUrlEncoded parser,JSON 表单输入不是这个业务入口的契约。

POST、PUT、PATCH 的该提取路径不使用 query 补齐字段。实验将 name 放到 POST query,body 只包含数量和确认值,name 仍得到 required 错误。这个行为应按方法与入口说明,不能推广为所有绑定操作都会忽略 query。

Required、长度和类级校验

ReservationForm 使用 Integer 保存数量与确认值,避免把“缺失”混成原始类型的0。quantity 具有 Required、Min(1)、Max(5),confirmation 具有 Required。name 具有 Required 与 MaxLength(40),类本身再标注 @Constraints.Validate。

RequiredValidator对字符串检查是否 empty;只有空格的字符串并不 empty。当前类级方法另用 isBlank() 返回 name.blank,才实现空白拒绝。

类级 validate 还检查 quantity 与 confirmation 相等:

1
2
3
4
5
6
7
8
9
10
11
12
@Override
public List<ValidationError> validate() {
List<ValidationError> errors = new ArrayList<>();
if (name != null && name.isBlank()) {
errors.add(new ValidationError("name", "name.blank"));
}
if (quantity != null && confirmation != null
&& !quantity.equals(confirmation)) {
errors.add(new ValidationError("confirmation", "quantity.mismatch"));
}
return errors;
}

null 防护是必要的。缺失字段或绑定错误可能使类内值不可用,不能假定所有字段验证已经成功才调用类级方法。返回空 List 表示当前类级检查没有错误,不表示业务库存已经满足条件。

MaxLengthValidator调用 String.length(),单位是 UTF-16 code unit。补充平面的 😀 占两个单位,本次表单提交21个得到长度42并拒绝;前一篇 JSON 接口按码点计数,40个仍可接受。这是两种明确不同的长度契约,不能都写成同一个“40字符限制”。

改成另一种计数规则时,应同时修改约束、前端说明和边界测试。码点也不等于字形,语言和组合字符的产品约定需要另行决定。

有效输入仍可能遇到业务竞争

StockService 是当前应用中的单例,初始库存为1,使用 AtomicInteger 的 compareAndSet 循环:

1
2
3
4
5
6
7
8
9
10
public boolean reserve(int quantity) {
if (quantity < 1 || quantity > 5)
throw new IllegalArgumentException("quantity outside lab bounds");
int current;
do {
current = remaining.get();
if (current < quantity) return false;
} while (!remaining.compareAndSet(current, current - quantity));
return true;
}

读取足够并不形成承诺;只有 CAS 成功才完成扣减。竞争者修改值后,失败的 CAS 重新读取,再判断剩余库存。两个 quantity=1 的有效请求最终只允许一个成功。

JUnit 用两个 ready 与共同 start 建立可控提交时点;真实 HTTP 也使用双客户端屏障。两者均得到201/409,随后库存查询为0。脚本再将合成库存重置为1,继续运行 CSRF 验证,因此 summary 的 final_stock=1 是清理后的状态,不能当成预约未扣减的证据。

这只证明单 JVM 内该 AtomicInteger 的操作。它没有数据库事务、持久订单或跨实例共享库存,不能承担多进程订单唯一性。第22–23篇才引入真实 PostgreSQL、连接与事务终态。

失败页面保存原始表达

绑定失败后,如果只把 DTO 转回表单,quantity=oops 已无法表示为 Integer。模板使用原始 values 回填输入框,并把全部原始值放到 submitted-values 列表。重复字段虽然不允许继续业务,两个提交值仍可用于解释错误。

回填有输出安全要求:输入属性和列表文本都走 Twirl 的普通字符串转义,不把输入包成 Html。真实浏览器提交 <script>window.formProbe=1</script> 与非法数量 oops 后,页面显示 FORM_INVALID,数量仍为 oops,脚本文字也保留;submitted-values 内没有 script 元素。

真实浏览器中的表单验证失败与原始输入回显

GET 页面使用 @AddCSRFToken,模板的 action 经 helper.CSRF 携带 token。浏览器以自己的 Cookie 和该 action 提交,没有使用实验 bypass 头。真实 HTTP 另用相同 Cookie 对照:有 token 的非法数量到达验证并返回400;缺 token 的有效字段返回403。

这些结果只证明本机 Cookie/token 路径。实验矩阵其他请求采用的 X-Play-Lab 合成头仅用于减少无关前置条件,不能成为生产 CSRF 安全结论。跨域凭据与代理信任继续由第27篇覆盖。

重跑与练习

从第00篇取得累计工程,按 RUN.md 准备版本,在 play-lab/ 执行:

1
2
3
bash sbtw test stage
python3 lab/content_checks.py --dev
python3 lab/content_checks.py

累计19项 JUnit 通过,HTTP 矩阵同时保留 JSON、Form、库存、CSRF 与 Assets 观测。浏览器证据独立保存为 browser-receipt.json 与截图,不把 Helpers 路由测试称为真实浏览器测试。

表单输入 结果 库存影响
quantity=abc 400,绑定失败 不扣减
quantity=0 或6 400,范围约束失败 不扣减
quantity=1,confirmation=2 400,跨字段失败 不扣减
重复 quantity=1,5 严格入口400,普通入口取1 普通入口只观察
两个有效 quantity=1 一个201,一个409 剩余0,随后重置

反例题:将库存查询加入 validate,然后在控制器普通减一,能否避免两个有效请求超卖?验证与修改之间没有原子性,两个请求可以分别看到同一份库存。

改动练习:把 name 的表单约束改成40码点,保留 null/blank 规则,增加20、21、40、41个 😀 与组合字符用例。用结果解释计数口径,保持原始失败表达与安全回填。不要通过截断输入来让验证静默通过。

上一篇:JSON 字段与错误契约。下一篇:Twirl 与静态资源。源码与重跑基线:最小应用。