功能结构图先定「有哪些模块」,流程图再定「模块之间怎么走」,目录结构基本就跟着定了。 订单状态是设计里唯一「错了会赔钱」的地方:七种状态、七条迁移,写错一条就出乱账。 把迁移规则做成一张表而不是一串 if,新增流程只插一行数据。
教材 24.3 讲功能结构图、系统流程图、开发环境、文件夹组织结构、系统预览。 这一篇挑两件事讲透:状态怎么管、目录怎么分。 结构图和流程图这两样,很多人画完就扔。 其实它们的用处是「提前发现漏掉的模块」——图上没有的,代码里一定没有。 这一节真正的技术内容是订单状态。 它是全书唯一一个「逻辑写错会直接造成财务损失」的地方,而它需要的技术含量极低。 演示里把七种状态和七条迁移做成一张表,再用一个函数查表决定放行还是拒绝。
一、功能结构图:先把树长全
商城的结构图大概是这个样子(只列两级):
网络商城
├── 前台
│ ├── 商品列表 / 搜索 / 详情
│ ├── 购物车
│ ├── 下单与支付
│ └── 会员中心(注册、登录、我的订单)
└── 后台
├── 商品管理(增删改查、上下架)
├── 类别管理
├── 订单管理(查询、发货、退款)
└── 会员管理
画这棵树的目的不是好看,是「对账」:每个叶子节点后面都要能写出一段代码, 写不出来的就是漏掉的模块。 反过来也成立——代码里出现了树上没有的功能,说明设计时漏了这一块,得补回去再写。 这棵树还决定了目录结构:一个叶子一个包,不多不少。 包比叶子多,就是有人提前设计了「以后可能用到」的东西;包比叶子少,就是几个模块挤在一起。
二、目录结构跟着模块走
| 目录 | 放什么 | 对应树上哪一支 |
|---|---|---|
action/ |
Struts 的 Action 类,一个模块一个 | 每个叶子 |
service/ |
业务逻辑与事务边界 | 跨叶子的流程 |
dao/ |
数据访问,只写 SQL | 每张表 |
entity/ |
与表一一对应的 Java 类 | 每张表 |
struts.xml 等配置 |
把 Action、Bean、映射连起来 | 全局 |
第三行和第四行常被合成一个包,那是省事; 分开的好处是「只写 SQL 的地方」和「只描述表的地方」各归各,改一个字段不用翻两个文件。 判断目录分得对不对,有个简单办法:随便挑一个需求,说出「要改哪几个文件」。 说得出来,结构就是清楚的;说不出来,说明文件散在四处,或者一个文件管了太多事。
三、七种状态,七条迁移
| 状态 | 含义 | 下一步 |
|---|---|---|
| 待付款 | 下单了没付钱 | 买家付款 → 待发货 |
| 待发货 | 付了钱等发货 | 后台发货 → 已发货 |
| 已发货 | 在路上 | 买家确认 → 已完成 |
| 已完成 | 交易结束 | 不再变 |
| 已取消 | 买家取消或超时 | 不再变 |
| 退款中 | 申请退款 | 后台处理 → 已退款 |
| 已退款 | 钱退回去了 | 不再变 |
画成有向图,所有箭头都从「待付款」出发,最后汇到三个终态:已完成、已取消、已退款。 图上没有回头的箭头——这就是这张图最重要的性质。 「已发货 → 待付款」在图上不存在,所以它在业务上也不该存在。 要把这个「不该」变成「不可能」,就得让代码每次改状态之前先问一句:这一对允许吗。
四、把规则从代码挪到数据
最直接的写法是一串判断:
if ("待付款".equals(state) && "待发货".equals(target)) {
// 放行
} else if ("待发货".equals(state) && "已发货".equals(target)) {
// 放行
} else {
throw new RuntimeException("非法状态流转");
}
七条还能忍。等到业务加了「部分退款」「换货」「超时自动取消」,这串 if 会长到没人敢改, 而每加一条就多一次写错的机会。 换成一张表就简单了:每次迁移先查「这一对在不在表里」,在就放行,不在就拒绝。
| 写法 | 加一条新流程要改什么 | 漏掉一条会怎样 |
|---|---|---|
| 一串 if | 加一个 else if | 静默走到最后的 else |
| 一张表 | 插一行 INSERT | 拒绝执行,报错 |
第二行才是关键:表里没有的组合,代码会走「拒绝」这个出口,而不是「放行」。 这叫【默认拒绝】,是安全设计里最常用的一条原则。
五、动手 Demo:订单状态:一张表管住七种流转
这一段建一张迁移表 tb_trans,把七条合法迁移插进去,然后拿四条迁移去试:两条合法、两条荒唐。
go() 函数只查表,不含任何业务判断。
import sqlite3
c = sqlite3.connect(u"mall.db", isolation_level=None)
c.execute(u"CREATE TABLE IF NOT EXISTS tb_trans (frm TEXT, to_ TEXT,"
u" PRIMARY KEY (frm, to_))")
c.executemany(u"INSERT OR IGNORE INTO tb_trans VALUES (?,?)", [
(u"待付款", u"待发货"), (u"待付款", u"已取消"),
(u"待发货", u"已发货"), (u"待发货", u"退款中"),
(u"已发货", u"已完成"), (u"已发货", u"退款中"),
(u"退款中", u"已退款"),
])
c.execute(u"CREATE TABLE IF NOT EXISTS tb_order (order_no TEXT PRIMARY KEY,"
u" state TEXT)")
c.execute(u"INSERT OR REPLACE INTO tb_order VALUES ('T001', '待付款')")
def go(frm, to):
ok = c.execute(u"SELECT 1 FROM tb_trans WHERE frm = ? AND to_ = ?",
(frm, to)).fetchone()
if not ok:
return u"拒绝:%s → %s 不是合法迁移" % (frm, to)
c.execute(u"UPDATE tb_order SET state = ? WHERE order_no = 'T001'", (to,))
return u"允许:%s → %s" % (frm, to)
for a, b in ((u"待付款", u"待发货"), (u"待发货", u"已发货"),
(u"已发货", u"待付款"), (u"已完成", u"待付款")):
print(go(a, b))
print(u"最终状态:%s" % c.execute(u"SELECT state FROM tb_order").fetchone()[0])
c.close()
实跑输出:
--------------------------------------------------------------------
订单状态:一张表管住六种流转
--------------------------------------------------------------------
**教材 24.3 讲系统设计:功能结构图、系统流程图、开发环境、目录结构、系统预览。**
**这一节抓一个具体的点:订单状态怎么设计,才不会出现「已发货又变成待付款」。**
① 商城订单的七种状态
--------------------------------------------------------------------
状态 含义 谁能改
-----------------------------------------
待付款 下单了没付钱 买家付款 → 待发货
待发货 付了钱等发货 后台发货 → 已发货
已发货 在路上 买家确认 → 已完成
已完成 交易结束 不再变
已取消 买家取消或超时 不再变
退款中 申请退款 后台处理 → 已退款
已退款 钱退回去了 不再变
**七种状态、七条迁移,画出来是一张有向图。**
**关键不在于「有几种状态」,而在于「哪些迁移是允许的」。**
**比如「已完成 → 待付款」是绝对不允许的,而这条规则如果不写进代码,**
**一次误操作就能把财务数据搞乱。**
② 用一张迁移表把规则变成数据
--------------------------------------------------------------------
尝试的迁移 结果
---------------------------------------------------
待付款 → 待发货 允许:待付款 → 待发货
待发货 → 已发货 允许:待发货 → 已发货
已发货 → 待付款 拒绝:已发货 → 待付款 不是合法迁移
已完成 → 待付款 拒绝:已完成 → 待付款 不是合法迁移
**前两条放行,后两条拒绝。**
**注意第三条「已发货 → 待付款」——它在业务上是荒唐的,**
**但如果代码里只写 `UPDATE ... SET state = ?`,数据库不会拦。**
**把迁移规则做成一张表,好处是「规则可查、可改、可测」。**
**新增一条流程(比如「已完成 → 退款中」),只要插一行数据,不用改代码。**
**这是「把规则从代码里挪到数据里」的典型例子,和 22 章那个「模块×表矩阵」是一个思路。**
这一段把七条合法迁移插进 tb_trans,然后拿四条去试:前两条合法、后两条荒唐。
go() 函数只有五行:查表、放行或拒绝、改状态。规则全在数据里,函数本身不含任何业务判断。
注意后两条是「被拒绝」,而不是「改了但没报错」。
这两种差别的代价差得很远:静默改掉会留下一条查不出原因的错账,拒绝执行会在日志里留一行。
新增一条流程(比如「已完成 → 退款中」)只要插一行数据,代码一行不动。
这是「把规则从代码挪到数据」的典型收益,和 22 章那个「模块×表矩阵」是同一个思路。
30 秒实验
- 往
tb_trans插一行「已完成 → 退款中」,重跑,看它是否放行。 - 把
go()改成「先改状态再查表」,看四条测试的结果变成什么样。 - 给
tb_order的state加CHECK (state IN (...)),再试写一个不存在的状态。
常见误区
- 状态用数字(0/1/2)存。半年后没人记得 3 是什么。 存中文或英文常量,多占几个字节,省下的排查时间远超这点空间。
- 只在前端限制状态。前端能改的东西都能绕过。 状态校验必须落在服务端,最好落成数据库里的一张表。
- 允许「回头」的迁移。一旦允许「已完成 → 待付款」,账目就没法核对了。 要纠错就新增一条退款流程,不要改历史状态。
自测三问(点开对答案)
1. 为什么把迁移规则做成表,而不是写成一串 if? —— 可查、可改、可测;而且表里没有的组合会走「拒绝」出口,天然是默认拒绝。 一串 if 的默认出口往往是「放行」,这是最危险的默认值。 2. 「已发货 → 待付款」为什么必须拒绝? —— 状态只能往前走。 回头就破坏了对账口径——货已经出去了,账却回到「没付钱」,两边的数字从此对不上, 而且找不到是哪一天错的。 3. 目录结构分得对不对,怎么快速验证? —— 随便挑一个需求,说出要改哪几个文件。 说不出来就是分错了:要么文件散在四处,要么一个文件管了太多事。设计定了,下一篇落数据库:五张核心表怎么连、明细表为什么要存两份冗余、 以及约束能拦住哪些错——三条都靠演示里的实测说话。
- ☐ (本篇完,下一篇 24.4 数据库设计)