Old-School Backend / 2026 ・ No.06

古法后端入门

第6讲 输入检查 ── 检查什么、在哪里检查、检查到什么程度

发布

终端画面。用 python3 send.py c1 发给忘了加 @Valid 的窗口,项目为空、项目只有全角空格、金额为负数、金额为 0 都以 200 存进了数据库,没有日期的因数据库 not-null 违反而 500。用 send.py c2 发给有 @Valid 的窗口,5 件都是 400:item: must not be blank、amount: must be greater than 0、date: must not be null
把同样的 5 件申请,发给忘了加 @Valid 的窗口(上)和加了的窗口(下)。忘了加时,空的项目和负数金额都存进了数据库(笔者于 2026 年 10 月 31 日在云端 Linux 上截图。窗口顶部的标题栏是为了说明而后加的)

上一讲,Jackson 默默丢掉了不认识的项目。那么,空的项目和负数金额会在哪里被挡住呢?前端篇第 9 讲在画面上做了输入检查,但服务器也会收到不经过画面的申请。
这次把申请存进数据库之前的关卡逐一试一遍:@Valid 与 Bean Validation、只靠标记不够的地方、JSON 阶段、“今天”是哪里的今天,以及最后防线 数据库约束。

一

在入口检查

新井输入检查的话,前端篇已经在画面上做了。金额为空就变红的那个。

古井那是对用户的体贴。用 curl 发过来,就不经过画面了。

新井会有那种人吗?

古井有。别的系统、旧的应用、恶作剧。服务器不管是谁发来的,都要检查。

全 15 讲路线图(当前:第 6 讲)

1 全景图 2 Java 准备 3 裸写 HTTP 4 裸写 JDBC 5 Spring Boot 6 输入检查 7 异常与回复 8 数据库访问 9 事务 10 状态流转 11 认证与授权 12 文件 13 日志与监控 14 测试 15 运行起来 当前位置 基础 徒手 入口 数据 业务与防护 运维
  • @Valid ── 忘了加的窗口,空的项目和负数金额都进了数据库(实测)
  • 标记不够 ── 1280.125 日元被默默变成 1280.13 日元,101 个字符的项目变成 500(实测)
  • JSON 阶段 ── 字符串 "1280" 能通过,全角 "1280" 是 400。不认识的项目一个设置就能拒绝(实测)
  • 今天 ── 东京今天的申请,被 UTC 的应用当作“未来”拒绝(实测)
  • 数据库约束 ── 加上 CHECK,连不经过应用的写入也挡住了(实测)

舞台和之前一样是三台机器。应用是在上一讲的 Spring Boot 4.1.2(Hibernate Validator 9、Jackson 3)上,加了三个检查强度不同的窗口:/api/novalid(没有 @Valid)、/api/v1(第 1 讲的标记)、/api/v2(严格的标记)。申请从 PC 发送,受理的申请连存进数据库的内容都确认了。

二

忘了加 @Valid 的话

第 1 讲的申请形式(ExpenseRequest)上已经有标记了。可是,标记只有在窗口的参数上加了 @Valid 时才起作用。

@PostMapping("/api/novalid/expenses")  // 忘了加 @Valid 的窗口
public Map<String, String> noValid(@RequestBody ExpenseRequest req) {
    return Map.of("id", service.submit(req));
}

@PostMapping("/api/v1/expenses")  // 第 1 讲的标记(@NotNull、@NotBlank、@Positive)
public Map<String, String> v1(@Valid @RequestBody ExpenseRequest req) {
    return Map.of("id", service.submit(req));
}
# 忘了加 @Valid 的窗口(/api/novalid/expenses)
  a 项目为空                 200  存进数据库: 2026-10-28||1280.00
  a2 项目只有全角空格        200  存进数据库: 2026-10-28| |1280.00
  b 金额为负数               200  存进数据库: 2026-10-28|交通費|-1280.00
  c 没有日期                 500  ERROR: null value in column "applied_on" of relation "expense" violates not-null constraint
  d 金额为 0                 200  存进数据库: 2026-10-28|交通費|0.00
# @Valid 和第 1 讲的标记(/api/v1/expenses)
  a 项目为空                 400  item: must not be blank
  a2 项目只有全角空格        400  item: must not be blank
  b 金额为负数               400  amount: must be greater than 0
  c 没有日期                 400  date: must not be null
  d 金额为 0                 400  amount: must be greater than 0

没有 @Valid 的窗口,把空的项目、只有全角空格的项目、负数金额、0 日元都原样存进了数据库。只有没有日期的申请变成了 500,但拒绝它的不是应用,而是数据库的 NOT NULL。加了 @Valid 的窗口,5 件都以 400 拒绝,日志里留下了哪个项目、为什么不行。只忘了一个词,标记就全都成了摆设。

三

Bean Validation 的标记

public record ExpenseRequest(
        @NotNull LocalDate date,
        @NotBlank String item,
        @NotNull @Positive BigDecimal amount) {
}

加在项目上的 @NotNull(不是 null)、@NotBlank(不只是空白的文字)、@Positive(大于 0),是 Bean Validation(Jakarta Validation)的标记。在 Spring Boot 中由 Hibernate Validator 读取,在交给窗口之前检查带 @Valid 的参数。只有全角空格的项目,也被 @NotBlank 拒绝了。

标记的好处是,“金额是正数”这样的规则就写在申请形式旁边。不过 400 的回复和上一讲一样只有“Bad Request”,哪个项目不对,传不到用户那里。把回复做成能传达这些,是下一讲的工作。

四

能通过标记、却很麻烦的申请

第 1 讲的标记只是最低限度。发送了 6 件能绕过标记、却会带来麻烦的申请。

# 能通过第 1 讲的标记、却很麻烦的申请(/api/v1/expenses)
  e 项目前后有空格           200  存进数据库: 2026-10-28| 交通費 |1280.00
  f 金额 1280.125            200  存进数据库: 2026-10-28|交通費|1280.13
  g 项目 101 个字符          500  ERROR: value too long for type character varying(100)
  h 金额 1 万亿日元          500  ERROR: numeric field overflow
  i 未来的日期               200  存进数据库: 2027-04-01|交通費|1280.00
  j 6 万日元、没有理由       200  存进数据库: 2026-10-28|接待費|60000.00
  • 前后有空格 ── “ 交通費 ”连同空格一起保存了。统计时会和“交通費”成为不同的项目
  • 1280.125 日元 ── 数据库的 numeric(12,2) 默默舍入成了 1280.13 日元。谁也没有决定舍入方式(第 2 讲)
  • 101 个字符的项目、1 万亿日元 ── 应用放行了,数据库的类型(varchar(100)、numeric(12,2))拒绝,变成 500。在用户看来是“服务器故障”
  • 未来的日期、没有理由的 6 万日元 ── 业务上想拒绝,但没有标记,所以被保存了
五

严格的标记,以及检查前的清理

做了一个加了标记的申请形式(StrictExpenseRequest)。

public record StrictExpenseRequest(
        @NotNull @PastOrPresent LocalDate date,  // 不允许未来的日期
        @NotBlank @Size(max = 100) String item,  // 与数据库的 varchar(100) 一致
        @NotNull @Positive @Digits(integer = 9, fraction = 0) BigDecimal amount,  // 日元。没有小数,最多 9 位
        @Size(max = 200) String reason) {

    public StrictExpenseRequest {
        if (item != null) item = item.strip();  // 去掉前后空格(含全角)后再检查
        if (reason != null) reason = reason.strip();
    }

    @AssertTrue(message = "5万円を超える申請には理由が必要です")
    public boolean isReasonGivenForLargeAmount() {  // 跨项目的规则
        return amount == null || amount.compareTo(new BigDecimal("50000")) <= 0 || (reason != null && !reason.isEmpty());
    }
# 同样的申请,发给严格标记的窗口(/api/v2/expenses)
  e 项目前后有空格           200  存进数据库: 2026-10-28|交通費|1280.00
  f 金额 1280.125            400  amount: numeric value out of bounds (<9 digits>.<0 digits> expected)
  g 项目 101 个字符          400  item: size must be between 0 and 100
  h 金额 1 万亿日元          400  amount: numeric value out of bounds (<9 digits>.<0 digits> expected); reasonGivenForLargeAmount: 5万円を超える申請には理由が必要です
  i 未来的日期               400  date: must be a date in the past or in the present
  j 6 万日元、没有理由       400  reasonGivenForLargeAmount: 5万円を超える申請には理由が必要です
  k 6 万日元、有理由         200  存进数据库: 2026-10-28|接待費|60000.00

第 1 讲的标记与严格的标记(实测)

申请 第 1 讲的标记(v1) 严格的标记(v2) 前后有空格 按“ 交通費 ”原样保存 strip 后保存 1280.125 日元 舍入为 1280.13 保存 400 @Digits 项目 101 个字符 500(数据库拒绝) 400 @Size(max=100) 1 万亿日元 500(数据库拒绝) 400 @Digits 未来的日期 原样保存 400 @PastOrPresent 6 万日元・没有理由 原样保存 400 @AssertTrue 标记不够,要么被默默保存,要么一路到数据库变成 500 让数据库的类型(varchar(100)、numeric(12,2))和标记里的数字一致 0.005 日元的舍入,是数据库默默做的。钱的尾数,按第 2 讲由自己决定 空格要在检查之前去掉(规范化)。检查之后再改,改过的值就没被检查过
  • 与数据库的类型一致 ── @Size(max = 100) 和 @Digits(integer = 9, fraction = 0)。原本在数据库变成 500 的,变成了 400
  • 检查之前先清理 ── 在 record 的构造函数里 strip()(全角空格也去掉)。检查之后再改,改过的值就没被检查过
  • 跨项目的规则 ── “超过 5 万日元需要理由”要同时看金额和理由。用加了 @AssertTrue 的方法就能写

不过,标记能写的,只到“只看这一件申请就能决定的事”。像“同一天已经有同样金额的交通费”“上级已批准的申请不能修改”这样不查数据库就不知道的规则,是负责人(Service)的工作(第 10 讲)。

六

在 JSON 阶段发生的事

在 Bean Validation 之前,Jackson 先把 JSON 变成 Java 的值。这里也有被拒绝的,和意外通过的。

# 在 JSON 阶段发生的事(/api/v2/expenses)
  l 金额是字符串 "1280"      200  存进数据库: 2026-10-28|交通費|1280.00
  m 金额是全角 "1280"    400  Cannot deserialize value of type `java.math.BigDecimal` from String "1280": not a valid representation
  n 金额是 "1,280"           400  Cannot deserialize value of type `java.math.BigDecimal` from String "1,280": not a valid representation
  o 项目是数字 123           200  存进数据库: 2026-10-28|123|1280.00
  p 金额是 true              400  Cannot deserialize value of type `java.math.BigDecimal` from Boolean value (token `JsonToken.VALUE_TRUE`)
  q 2 月 30 日               400  Cannot deserialize value of type `java.time.LocalDate` from String "2026-02-30": Failed to deserialize `java.time.LocalDate`
  r 不认识的项目 receipts    200  存进数据库: 2026-10-28|交通費|1280.00
# 用 spring.jackson.deserialization.fail-on-unknown-properties=true 启动时
  r 不认识的项目 receipts    400  Unrecognized property "receipts" (class example.expenses.StrictExpenseRequest), not marked as ignorable

字符串 "1280" 被变成数字,数字 123 被变成项目文字“123”,这是 Jackson 的贴心。另一方面,全角的 "1280" 是 400。第 2 讲里 new BigDecimal("1280") 能读成 1280,所以不同的工具答案不同。"1,280"、true、2 月 30 日也都是 400。

上一讲被默默丢掉的 receipts,加上 spring.jackson.deserialization.fail-on-unknown-properties=true 启动后,就能以 400 拒绝。画面和 API 由同一个团队做的话,设成拒绝、尽早发现发错,比较安全。反过来,对外公开、不断增加项目的 API,放过不认识的项目,才不会弄坏老用户。选哪种,是要决定并写下来的规则。

七

“今天”是哪里的今天

在东京的 10 月 31 日 0 点 55 分,用当天的日期试了严格标记里的 @PastOrPresent(今天或过去的日期)。

终端画面。TZ=Asia/Tokyo 的 date 是 2026-10-31 00:55:07 JST,TZ=UTC 的 date 是 2026-10-30 15:55:07 UTC。以申请日 2026-10-31 发送,结果 400,date: must be a date in the past or in the present
东京是 10 月 31 日 0 点多。用这个“今天”的日期申请,没有时区设置(UTC)的应用把它当作“未来的日期”拒绝了(笔者于 2026 年 10 月 31 日在云端 Linux 上截图。窗口顶部的标题栏是为了说明而后加的)
# 东京是 10 月 31 日 0 点多,UTC 是 10 月 30 日 15 点多。JVM 没有时区设置(UTC)
  s 今天(东京)2026-10-31   400  date: must be a date in the past or in the present
# 同样的申请,发给用 -Duser.timezone=Asia/Tokyo 启动的应用
  s 今天(东京)2026-10-31   200  存进数据库: 2026-10-31|交通費|1280.00

“今天”是 JVM 时钟的今天(实测)

@PastOrPresent 的“今天”,是 JVM 时钟的今天 东京(用户) 00:55 2026-10-31 JVM(无设置=UTC) 15:55 2026-10-30 申请日 2026-10-31 未来 → 400 对用户来说是今天。对 JVM 来说,还是明天 同样的申请,在用 -Duser.timezone=Asia/Tokyo 启动的应用里以 201 通过(实测) 每天 0 点到 9 点,东京“今天”的申请都会被拒绝 和第 2 讲的作业同根。用到“今天”的规则,先决定是哪里的今天 在启动时定好时区(-Duser.timezone),或者把公司所在地的时钟作为规则

应用的 JVM 没有时区设置,按 UTC 运行。UTC 还是 10 月 30 日,所以东京今天的申请被当作“未来的日期”拒绝了。每天 0 点到 9 点,东京的用户都没法提交今天的申请。和第 2 讲的作业是同一个根源。用 -Duser.timezone=Asia/Tokyo 启动后,同样的申请以 201 通过了。用到“今天”的规则,要先决定是哪里的今天。

新井半夜提交申请的人……

古井有人出差坐末班车回来,在家里提交。越是这种人,被拒绝了越生气。

八

最后防线:数据库约束

应用的标记,只对经过应用的申请有效。迁移脚本、别的系统、用 psql 手工操作,都会绕过应用直接写数据库。所以,让数据库自己也带上规则。

终端画面。psql 的 \d expense 显示 Check constraints: amount_positive CHECK (amount > 0::numeric)。从没有 @Valid 的窗口发送负数金额,返回 500,violates check constraint amount_positive。用 psql 直接 insert 也是 ERROR: new row for relation expense violates check constraint amount_positive
给数据库加上“金额大于 0”的 CHECK 约束后,无论是从没有 @Valid 的窗口,还是用 psql 直接写,负数金额都进不去了(笔者于 2026 年 10 月 31 日在云端 Linux 上截图。窗口顶部的标题栏是为了说明而后加的)
# 给数据库加上 CHECK 约束
alter table expense add constraint amount_positive check (amount > 0);
# 从没有 @Valid 的窗口发送负数金额
  b 金额为负数               500  ERROR: new row for relation "expense" violates check constraint "amount_positive"
# 即使用 psql 直接写入
ERROR:  new row for relation "expense" violates check constraint "amount_positive"
DETAIL:  Failing row contains (EXP-2026-9999, 2026-10-28, 交通費, -1280.00, submitted, 2026-10-31 00:53:08.807753+09, 新井).

加上 CHECK (amount > 0) 之后,无论从没有 @Valid 的窗口,还是用 psql 直接写,负数金额都进不去了。第二章挡住没有日期的申请的,也是数据库的 NOT NULL。不过数据库拒绝时,应用会抛出异常,给用户的回复是 500。数据库约束是“最后防线”,给用户的细致说明是前面关卡的工作。

九

检查什么、在哪里检查

申请存进数据库之前的关卡

画面 前端篇 required min・max (可被绕过) JSON Jackson 类型是否相符 2月30日 不认识的项目 形式 Validation 空・null 范围・位数・长度 未来的日期 规则 负责人 跨项目 查数据库判断 (第7・10讲) 数据库 PostgreSQL NOT NULL varchar(100) CHECK 申请存进数据库之前的关卡 画面的检查是为了用户。保护服务器的,是后面 4 道 越靠前,越能早而具体地拒绝;越靠后越可靠,但拒绝得越粗糙(500) JSON:类型不符就是 400。不认识的项目,默认会被默默丢掉 形式:只在加了 @Valid 的窗口生效。忘了加,就什么都不检查 数据库:连不经过应用的写入也能挡住的最后防线。但回复是 500
POINT

画面的检查是为了用户,服务器的检查是为了数据。同样的规则两边都写,并不是浪费。服务器上有:JSON 的类型(Jackson)、项目的形式(Bean Validation 与 @Valid)、查数据库决定的业务规则(负责人),以及数据库约束。越靠前越能早而清楚地拒绝,越靠后越可靠。

十

在自己的电脑上验证

想试的事写法
让标记在窗口生效@Valid @RequestBody StrictExpenseRequest req
与数据库的长度和位数一致@Size(max = 100)、@Digits(integer = 9, fraction = 0)
跨项目的规则@AssertTrue public boolean isReasonGiven…()
拒绝不认识的项目spring.jackson.deserialization.fail-on-unknown-properties=true
让 JVM 的今天是东京的今天java -Duser.timezone=Asia/Tokyo -jar app.jar
让数据库也带上规则alter table expense add check (amount > 0);

标记的提示信息会随 JVM 的语言设置变化。这次实验是 LANG=C.UTF-8,所以是英文。不依赖标记默认的提示、自己决定给用户看的文字,也放在下一讲的回复写法里讨论。

结 — 一页总结与下一讲

  1. 画面的检查是为了用户。服务器不管是谁发来的都要检查。
  2. Bean Validation 的标记只在加了 @Valid 的窗口起作用。忘了加,空的和负数都进了数据库。
  3. 标记要与数据库的类型一致。不够的话,要么被默默舍入,要么一路到数据库变成 500。
  4. 检查之前先清理(strip)。跨项目的规则用 @AssertTrue,查数据库的规则交给负责人。
  5. JSON 阶段:字符串 "1280" 能通过,全角是 400。拒不拒绝不认识的项目,要决定并写下来。
  6. “今天”是 JVM 的今天。UTC 的应用,每天 0~9 点都拒绝东京今天的申请。
  7. 数据库约束是最后防线,连不经过应用的写入也能挡住。但回复是 500。

新井能拒绝了,可回复只有“Bad Request”和“500”,总觉得不够体贴。

古井下一讲就讲这个。在哪里接住异常,用什么状态码,写什么内容返回。从古老的“系统发生错误”毕业吧。

参考

记录是笔者于 2026 年 10 月 31 日(东京)在云端 Linux(Ubuntu 24.04,内核 6.18)上,用 Spring Boot 4.1.2(Hibernate Validator 9、Jackson 3)、OpenJDK 25.0.4、PostgreSQL 16.15 取得的。PC・申请服务器・数据库服务器,是在一台机器里建立的不同网络空间(network namespace)。“今天”的实验在东京 0 点多(UTC 15 点多)进行。表中的数字和输出,全部是从当时命令输出中读取的实际值。登场人物均为虚构。

古法后端入门 第6讲 — 输入检查 / 2026.10

← 返回文章列表