Old-School Backend / 2026 ・ No.10

古法后端入门

第10讲 状态迁移 ── 已批准的申请,谁能改?

发布

终端画面。Action.java 的 5 行是 SUBMIT(SUBMITTED, DRAFT, RETURNED)、APPROVE(APPROVED, SUBMITTED)、RETURN(RETURNED, SUBMITTED)、REJECT(REJECTED, SUBMITTED)、PAY(PAID, APPROVED)。show10.py matrix 的表中,只有 draft 的 submit,submitted 的 approve・return・reject,returned 的 submit,approved 的 pay 是 200,其余全是 409
状态迁移表(上),以及把 6 种状态 × 5 种操作全部试过的结果(下)。30 种里只有表中写的 6 种得到 200(笔者于 2026 年 10 月 31 日在云端 Linux 上截图。窗口顶部的标题栏是为了说明而后加的)

报销申请会按 草稿 → 申请中 → 已批准 → 已支付 前进。如果把这个“状态”只当作一列字符串,已批准申请的金额会被改掉,不可能的状态会写进数据库,两位审批人同时按下时,历史也会互相矛盾。
这一讲把状态和操作做成表,用 MyBatis 的 where 和数据库的 CHECK 守住,并留下历史。哪里会拦下什么,用 6×5 的全部组合和同时按下来实测。

一

已批准的申请能被修改

新井上一讲加了版本号,同时修改也安全了吧。

古井同时的话是。那么,我批准之后,你再去改那份申请呢?

新井……只要版本号对得上,就能通过。

古井试试看。

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

1 全景图 2 Java 准备 3 裸写 HTTP 4 裸写 JDBC 5 Spring Boot 6 输入检查 7 异常与回复 8 数据库访问 9 事务 10 状态流转 11 认证与授权 12 文件 13 日志与监控 14 测试 15 运行起来 当前位置 基础 徒手 入口 数据 业务与防护 运维
# 用第 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 种。从哪个状态、经哪个操作、能到哪个状态,画成一张图。

申请的状态与操作

申请的状态(6 种)与操作(5 种) draft 草稿 submitted 申请中 approved 已批准 paid 已支付・终点 returned 已退回 rejected 已驳回・终点 submit approve pay return submit reject 填色:可以修改内容的状态(draft・returned) 红框:终点状态,之后什么都不能做 没有箭头的移动全都“不可以”。做成表,30 种里只有 6 种可以 这一讲的批准只有 1 级(古井),支付由会计负责

把这张图原样写成 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>
终端画面。StatusMapper.xml 的 changeStatus 是 update expense set status = #{to}, version = version + 1 where id = #{id} and status = #{from}。show10.py race naive guarded 的结果:naive 批准 200×6、退回 200×4,最终状态 approved,历史 10 行,submitted→approved 与 submitted→returned 混在一起。guarded 批准 200×1、退回 200×0,历史 1 行
对 1 件申请中的申请,同时发批准 50 件和退回 50 件。朴素版有 10 件 200,历史里“申请中到批准”和“申请中到退回”混在一起。where 版只通过了 1 件(笔者于 2026 年 10 月 31 日在云端 Linux 上截图。窗口顶部的标题栏是为了说明而后加的)
# 对 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 id) 古井 会计 读出 submitted update → approved 读出 submitted update 等待 → returned 历史 submitted→approved furui submitted→returned keiri 两人都是 200,都“从申请中”出发 where 版(id 和 status) 古井 会计 读出 submitted update → approved 读出 submitted update 0 行 → 409 历史 submitted→approved furui 只有 1 人 200,后来的人 409 检查可以在 Java 里,但最后的关口是 update 的 where。与第 9 讲同一形式 实测:朴素版 200 有 3〜9 件,where 版 3 次都是 1 件

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>
终端画面。show10.py life 的结果:arai submit 200 submitted,furui return 200 returned,arai edit 200 amount=25,660,arai submit 200,furui approve 200 approved,arai edit 409 この申請は変更できません,keiri pay 200 paid,furui return 409 paid。历史是 draft→submitted arai、submitted→returned furui、returned→submitted arai、submitted→approved furui、approved→paid keiri
从草稿到支付,完整走了一遍。退回后可以修改,批准后不能修改。历史表里留下了谁把申请从哪个状态改到哪个状态(笔者于 2026 年 10 月 31 日在云端 Linux 上截图。窗口顶部的标题栏是为了说明而后加的)
# 从草稿到支付(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 挡回。

守护状态的五层(实测)

守护状态的五层(越上越友好,越下越坚固) 画面 只显示可按的按钮(GET 的 actions) submitted → [approve, return, reject] 窗口 以 enum 接收操作名 aproved → 400 未知操作 负责人 用状态迁移表检查 30 种中 24 种是 409 SQL where id = … and status = #{from} 同时按下也只有 1 件 200 数据库 CHECK (status in (…)) aproved → 500(写不进去) 即使隐藏了画面上的按钮,窗口也不算守住了 上层是易用性,下层是最后的安全网。只靠其中一层不够
POINT

状态不是“一列字符串”,而是用表决定的移动。列出可以做的事,其余拒绝。检查可以在 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 里没有状态的 updategrep -n -A2 '<update' src/main/resources/mapper/*.xml
查看某份申请的一生select * from expense_history where expense_id = '…' order by no;

统计状态种类时,只要出现一个表里没有的字符串,就说明某处有“原样写入字符串”的窗口。接手老系统时,先跑一下这条 select 吧。

结 — 一页总结与下一讲

  1. 只靠版本号,已批准的申请也能被修改(24,160 → 34,160 日元)。
  2. 把状态当字符串,返回 500 拼错的值却写进了数据库。
  3. 状态和操作放进迁移表(enum),30 种里只允许 6 种。
  4. 迁移的最后关口是 update 的 where status = #{from}。同时按下也只有 1 件 200。
  5. 状态一变,在同一事务里写历史。
  6. 画面按钮、窗口、负责人、where、CHECK,只靠其中一层不够。

新井这样,已批准的申请就安全了。

古井不过,这一讲里能批准的,是在 X-User 里写了 furui 的人。谁都能自称 furui,你也能自己批准自己的申请。下一讲是认证与授权,讲怎么确认“是谁”。

参考

记录是笔者于 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

← 返回文章列表