Old-School Backend / 2026 ・ No.10
古法后端入门
第10讲 状态迁移 ── 已批准的申请,谁能改?
发布
报销申请会按 草稿 → 申请中 → 已批准 → 已支付 前进。如果把这个“状态”只当作一列字符串,已批准申请的金额会被改掉,不可能的状态会写进数据库,两位审批人同时按下时,历史也会互相矛盾。
这一讲把状态和操作做成表,用 MyBatis 的 where 和数据库的 CHECK 守住,并留下历史。哪里会拦下什么,用 6×5 的全部组合和同时按下来实测。
已批准的申请能被修改
新井上一讲加了版本号,同时修改也安全了吧。
古井同时的话是。那么,我批准之后,你再去改那份申请呢?
新井……只要版本号对得上,就能通过。
古井试试看。
全 15 讲路线图(当前:第 10 讲)
# 用第 9 讲的窗口(带版本号)修改已批准的申请(EXP-2026-5002) GET /api/v9/claims/EXP-2026-5002 → 200 amount=24,160 version=0 (DB status=approved) PUT /api/v9/claims/EXP-2026-5002 amount=34,160 → 200 DB status|item|amount|version = approved|出張-002|34160.00|1
用上一讲的窗口打开一份已批准的出差申请(24,160 日元),对上版本号,加 10,000 日元保存。返回 200,数据库变成“已批准、34,160 日元”。可批准的明明是 24,160 日元的申请。
- 把状态当字符串 ── 拼错的“aproved”返回 500,却写进了数据库(实测)
- 状态迁移表 ── 6 种状态 × 5 种操作,30 种里只允许 6 种(实测)
- 同时按下 ── 同时发批准和退回,朴素版有 3〜9 件 200(实测)
- 守护的层 ── 画面、窗口、负责人、SQL 的 where、数据库的 CHECK
应用接着上一讲,用 MyBatis 写。数据库里新增了状态历史表 expense_history。操作的人和第 5 讲一样,用 X-User 头报上名字(arai・furui・keiri=会计)。
把状态当字符串
先看常见的写法。状态是 status 列里的字符串,把画面送来的字符串原样写入。
# 以字符串接收状态的窗口与负责人(朴素版)
@PutMapping("/api/v10/naive/claims/{id}/status") // 以字符串接收状态(朴素版)
public ClaimView10 setStatus(@PathVariable String id, @RequestBody Map<String, String> body,
@RequestHeader("X-User") String actor) {
return ClaimView10.of(naive.setStatus(id, body.get("status"), actor));
}
@Transactional // 把收到的字符串直接当作状态
public ClaimState setStatus(String id, String status, String actor) {
ClaimState cur = states.find(id);
if (cur == null) throw new ExpenseNotFoundException(id);
states.setStatus(id, status);
states.insertHistory(id, cur.status(), status, actor);
return states.find(id);
}
<update id="setStatus">
update expense set status = #{to} where id = #{id}
</update>
# 把收到的字符串直接当作状态(EXP-2026-5003) "aproved" → 500 DB status = aproved "承認" → 500 DB status = 承認 "paid" → 200 DB status = paid # 各状态的件数(给另外 2 件也写入 aproved 和 承認 之后) approved|1 aproved|1 paid|1 submitted|122 承認|1 # 500 的内容(应用日志) java.lang.IllegalArgumentException: No enum constant example.expenses.v10.ClaimStatus.APROVED
“aproved”(少了一个 p)和“承認”,都返回了 500,却已经写进了数据库。负责人的事务已经提交,之后窗口生成回复时,无法把不认识的状态转成 enum 而出错。画面上显示“错误”,数据库里却留下了拼错的值。这是最难察觉的形态。
“paid”是 200。申请中的申请跳过批准,直接成了已支付。一列字符串,什么字都收、什么顺序都收。
状态迁移表
状态定为 6 种,操作定为 5 种。从哪个状态、经哪个操作、能到哪个状态,画成一张图。
申请的状态与操作
把这张图原样写成 Java 的 enum。每个操作只写“去向”和“可出发的状态”。窗口把 URL 里的 approve 转成 Action.APPROVE 接收。
# 把状态和操作做成 enum(ClaimStatus.java 与 Action.java)
public enum ClaimStatus {
DRAFT, SUBMITTED, APPROVED, RETURNED, REJECTED, PAID;
…
}
/** 状态迁移表:每个操作“从哪些状态”“到哪个状态” */
public enum Action {
SUBMIT(SUBMITTED, DRAFT, RETURNED), // 提交(从草稿・退回)
APPROVE(APPROVED, SUBMITTED), // 批准
RETURN(RETURNED, SUBMITTED), // 退回
REJECT(REJECTED, SUBMITTED), // 驳回
PAY(PAID, APPROVED); // 支付
…
public boolean allowedFrom(ClaimStatus s) {
return from.contains(s);
}
}
6 种状态 × 5 种操作,30 种组合全部试了一遍。
# 6 种状态 × 5 种操作(EXP-2026-5004,每次重设状态) 当前状态 \ 操作 submit approve return reject pay draft 200 → submitted 409 409 409 409 submitted 409 200 → approved 200 → returned 200 → rejected 409 returned 200 → submitted 409 409 409 409 approved 409 409 409 409 200 → paid rejected 409 409 409 409 409 paid 409 409 409 409 409
得到 200 的只有表里写的 6 种,其余 24 种都是 409。已支付和已驳回之后什么都不能做,已批准之后只能支付。列出“可以做的事”,其余一律拒绝,这就是状态迁移表的思路。
用 where 守住迁移
用迁移表检查,还有漏洞。古井和会计在看同一份申请,古井按批准、会计按退回,同时按下呢?这和第 9 讲一样,是“读出、检查、写入”的竞争。
# 朴素版:用迁移表检查,但写入时只看 id
@Transactional // 用迁移表检查,但写入时不检查
public ClaimState act(String id, Action action, String actor) {
ClaimState cur = states.find(id);
if (cur == null) throw new ExpenseNotFoundException(id);
ClaimStatus from = ClaimStatus.fromDb(cur.status());
if (!action.allowedFrom(from)) throw new IllegalTransitionException(id, from, action);
states.setStatus(id, action.to().db());
states.insertHistory(id, from.db(), action.to().db(), actor);
return states.find(id);
}
# 在 where 里也检查的版本(ClaimFlowService.java 与 StatusMapper.xml)
@Transactional
public ClaimState act(String id, Action action, String actor) {
ClaimStatus from = ClaimStatus.fromDb(find(id).status());
if (!action.allowedFrom(from)) throw new IllegalTransitionException(id, from, action);
if (states.changeStatus(id, from.db(), action.to().db()) == 0) { // 读出之后,有人改了状态
throw new IllegalTransitionException(id, ClaimStatus.fromDb(find(id).status()), action);
}
states.insertHistory(id, from.db(), action.to().db(), actor); // 在同一事务里也写历史
return find(id);
}
<update id="changeStatus">
update expense set status = #{to}, version = version + 1
where id = #{id} and status = #{from}
</update>
# 对 1 件申请中的申请,同时发批准 50 件(furui)和退回 50 件(keiri)(各 3 次)
方式 批准 200 退回 200 最终状态 历史行数
naive 3 2 returned 5
2 1 approved 3
4 5 returned 9
guarded 0 1 returned 1
0 1 returned 1
0 1 returned 1
# 朴素版第 3 次的历史(前 6 行)
submitted→approved furui
submitted→approved furui
submitted→approved furui
submitted→returned keiri
submitted→returned keiri
submitted→returned keiri
朴素版对批准和退回都返回了 200(3 次分别 5・3・9 件)。历史里并排出现“申请中到批准”和“申请中到退回”,最终状态每次不同,有时是已批准,有时是已退回。于是会出现:古井的画面显示“已批准”,申请却是已退回。
同时按下时发生了什么
where 版只是在 update 上加了 and status = #{from}。只有先写的 1 件命中那一行,后来的人更新 0 行,返回 409。3 次都是正好 1 件 200,历史也只有 1 行(这 3 次都是退回在先,但谁先谁后每次都会变)。
能修改内容的状态
回到第一章“已批准的申请能被修改”。内容(品目、金额)只有在草稿和已退回时才能改,这也放进 update 的 where。因为有 2 个状态,用 MyBatis 的 <foreach> 拼出 in (…)。
# 把可修改内容的状态放进 where(foreach)
<update id="updateIfEditable">
update expense set item = #{item}, amount = #{amount}, version = version + 1
where id = #{id} and version = #{version}
and status in <foreach collection="editable" item="s" open="(" separator="," close=")">#{s}</foreach>
</update>
@Transactional // 只有草稿和退回时才能改内容
public ClaimState edit(String id, EditRequest req) {
if (states.updateIfEditable(id, req.item(), req.amount(), req.version(), EDITABLE) == 0) {
ClaimState c = find(id);
if (!EDITABLE.contains(c.status())) throw new NotEditableException(id, ClaimStatus.fromDb(c.status()));
throw new StaleClaimException(id);
}
return find(id);
}
# 用第 10 讲的窗口修改同一份已批准申请
PUT /api/v10/claims/EXP-2026-5002 → 409 この申請は変更できません(受付番号 EXP-2026-5002 は今 approved です)
更新 0 行时,再读一次,分清原因。原因是状态,就返回“这份申请不能修改”;原因是版本号,就返回上一讲的“别人先修改了”。两者都是 409,但画面上显示的话不同。
新井批准之后才发现金额写错了,怎么办?
古井请审批人退回。修改的路,应该永远在表上。开了后门,后门就会成为最常用的路。
留下历史
每次状态变化,都留下一行“从哪个状态、到哪个状态、谁、何时”。关键是与状态的 update 在同一个事务里写,这样就不会出现状态变了却没有历史的情况。
# 历史表(setup10.sql)与写入的 SQL
-- 第 10 讲:状态历史
create table if not exists expense_history (
no bigserial primary key,
expense_id varchar(20) not null references expense(id),
from_status varchar(20) not null,
to_status varchar(20) not null,
actor varchar(50) not null,
at timestamptz not null default now()
);
<insert id="insertHistory">
insert into expense_history (expense_id, from_status, to_status, actor)
values (#{id}, #{from}, #{to}, #{actor})
</insert>
# 从草稿到支付(EXP-2026-5006) 新井 submit 200 submitted actions=['approve', 'return', 'reject'] 古井 return 200 returned actions=['submit'] 新井 edit 200 returned amount=25,660 新井 submit 200 submitted actions=['approve', 'return', 'reject'] 古井 approve 200 approved actions=['pay'] 新井 edit 409 approved この申請は変更できません 会计 pay 200 paid actions=[] 古井 return 409 paid actions=[] # GET /api/v10/claims/EXP-2026-5006/history draft → submitted arai 00:51:29.164Z submitted → returned furui 00:51:29.178Z returned → submitted arai 00:51:29.221Z submitted → approved furui 00:51:29.234Z approved → paid keiri 00:51:29.255Z
退回之后,新井加上 1,500 日元出租车费重新提交。批准后的修改是 409,对已支付的退回也是 409。历史里有 5 行,申请的一生原样留了下来。会计或审计问“这份申请是谁、什么时候批准的”,就给他们看这张表。
画面的按钮与 409
画面按打开申请时的状态显示按钮。只要回复里附上“可做的操作”(actions),画面不用知道迁移表,也能只显示正确的按钮。可是,打开期间状态会变。
# 古井和会计打开同一份申请,会计先退回(EXP-2026-5007)
古井 open 200 status=submitted actions=['approve', 'return', 'reject']
会计 open 200 status=submitted actions=['approve', 'return', 'reject']
会计 return 200 status=returned actions=['submit']
# 古井按下打开时的按钮(批准)
HTTP 409
{
"detail": "受付番号 EXP-2026-5007 は今 returned です",
"instance": "/api/v10/claims/EXP-2026-5007/approve",
"status": "returned",
"title": "この操作はできません",
"type": "https://expenses.example/problems/illegal-transition",
"actions": [
"submit"
]
}
# 处理员在 409 里附上当前状态和可按的按钮(ApiErrors.java)
@ExceptionHandler(IllegalTransitionException.class) // 该状态下不能做该操作 → 409
ProblemDetail illegalTransition(IllegalTransitionException e) {
ProblemDetail p = problem(HttpStatus.CONFLICT, "illegal-transition", "この操作はできません",
"受付番号 " + e.id() + " は今 " + e.current().db() + " です");
p.setProperty("status", e.current().db());
p.setProperty("actions", Action.availableIn(e.current()));
return p;
}
古井的画面上还有“批准”按钮,但申请已经被退回。409 的回复里放了当前状态和现在可做的操作。画面据此显示“会计已先退回”,并重新排列按钮。
让数据库也持有列表
作为最后一张网,让数据库也持有“可能的状态”。和第 6 讲给金额加的 CHECK 一样。
# 让数据库也用 CHECK 持有可能状态的列表
alter table expense add constraint expense_status_valid
check (status in ('draft','submitted','approved','returned','rejected','paid'));
# 加上 CHECK 之后
PUT …/naive/claims/EXP-2026-5003/status "aproved" → 500 DB status = submitted
ERROR: new row for relation "expense" violates check constraint "expense_status_valid"
POST …/claims/EXP-2026-5003/aproved → 400 知らない操作です
POST …/claims/EXP-2026-5003/pay(submitted) → 409 この操作はできません
加上 CHECK 后,朴素版的拼写错误在数据库被拦下,状态仍是 submitted。回复同样是 500,但这次数据库里什么也没留下。用 enum 的窗口,则根本在入口就以 400 挡回。
守护状态的五层(实测)
状态不是“一列字符串”,而是用表决定的移动。列出可以做的事,其余拒绝。检查可以在 Java 里,但最后的关口是 update 的 where。改了之后,在同一事务里写历史。
手写到什么程度
这一讲的迁移表是 1 个 enum,不计空行 29 行。审批只有 1 级的话,这就够了。不过业务一成长,表很快就会变大。
| 方式 | 适合的场合 | 注意 |
|---|---|---|
| 手写迁移表(这一讲) | 状态大约 10 个以内,审批 1〜2 级,流程不常变 | 别忘了 where 和历史。条件一多,表就读不懂了 |
| 工作流引擎(Flowable 等) | 多级审批,按金额或部门换审批人,到期自动推进,想用图和业务人员讨论流程 | 学习工具本身的成本。申请表和引擎内部的状态会变成双份 |
无论选哪个,“拒绝不能做的操作”“同时按下只放行 1 件”“留下历史”这三点都一样。即使引入了引擎,只要还留着批准后能改金额的窗口,漏洞就依然存在。
在自己的电脑上验证
| 想试的事 | 写法 |
|---|---|
| 统计数据库里有哪些状态 | select status, count(*) from expense group by status; |
| 让数据库持有可能的状态 | alter table … add constraint … check (status in (…)); |
| 找出 where 里没有状态的 update | grep -n -A2 '<update' src/main/resources/mapper/*.xml |
| 查看某份申请的一生 | select * from expense_history where expense_id = '…' order by no; |
统计状态种类时,只要出现一个表里没有的字符串,就说明某处有“原样写入字符串”的窗口。接手老系统时,先跑一下这条 select 吧。
结 — 一页总结与下一讲
- 只靠版本号,已批准的申请也能被修改(24,160 → 34,160 日元)。
- 把状态当字符串,返回 500 拼错的值却写进了数据库。
- 状态和操作放进迁移表(enum),30 种里只允许 6 种。
- 迁移的最后关口是 update 的 where status = #{from}。同时按下也只有 1 件 200。
- 状态一变,在同一事务里写历史。
- 画面按钮、窗口、负责人、where、CHECK,只靠其中一层不够。
新井这样,已批准的申请就安全了。
古井不过,这一讲里能批准的,是在 X-User 里写了 furui 的人。谁都能自称 furui,你也能自己批准自己的申请。下一讲是认证与授权,讲怎么确认“是谁”。
参考
- MyBatis 3 — Dynamic SQL(foreach)
- MyBatis 3 — Mapper XML Files
- PostgreSQL 16 — Constraints(CHECK)
- Spring Framework — Transaction Management
- Spring Framework — Error Responses(ProblemDetail)
- Flowable Open Source Documentation
记录是笔者于 2026 年 10 月 31 日在云端 Linux(Ubuntu 24.04,内核 6.18)上,用 Spring Boot 4.1.2(Spring Framework 7.0.10、MyBatis 3.5.19、mybatis-spring-boot 4.1.0)、OpenJDK 25.0.4、PostgreSQL 16.15 取得的。申请服务器与数据库服务器是在一台机器里建的不同网络空间(network namespace),往返约 0.04 ms。同时按下的实验各做 3 次,结果每次运行都会变化(截图是另外的一次)。已批准的状态是实验前用 SQL 设置的。文中的数字和输出,都是从当时命令输出里读到的实际值。登场人物均为虚构。
古法后端入门 第10讲 — 状态迁移 / 2026.10