🛢️ 第24章 Struts2+Spring+Hibernate+MySQL实现网络商城
📄 一句话概括

功能结构图先定「有哪些模块」,流程图再定「模块之间怎么走」,目录结构基本就跟着定了。 订单状态是设计里唯一「错了会赔钱」的地方:七种状态、七条迁移,写错一条就出乱账。 把迁移规则做成一张表而不是一串 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 秒实验

  1. 往 tb_trans 插一行「已完成 → 退款中」,重跑,看它是否放行。
  2. 把 go() 改成「先改状态再查表」,看四条测试的结果变成什么样。
  3. 给 tb_order 的 state 加 CHECK (state IN (...)),再试写一个不存在的状态。

常见误区

  1. 状态用数字(0/1/2)存。半年后没人记得 3 是什么。 存中文或英文常量,多占几个字节,省下的排查时间远超这点空间。
  2. 只在前端限制状态。前端能改的东西都能绕过。 状态校验必须落在服务端,最好落成数据库里的一张表。
  3. 允许「回头」的迁移。一旦允许「已完成 → 待付款」,账目就没法核对了。 要纠错就新增一条退款流程,不要改历史状态。
自测三问(点开对答案) 1. 为什么把迁移规则做成表,而不是写成一串 if? —— 可查、可改、可测;而且表里没有的组合会走「拒绝」出口,天然是默认拒绝。 一串 if 的默认出口往往是「放行」,这是最危险的默认值。 2. 「已发货 → 待付款」为什么必须拒绝? —— 状态只能往前走。 回头就破坏了对账口径——货已经出去了,账却回到「没付钱」,两边的数字从此对不上, 而且找不到是哪一天错的。 3. 目录结构分得对不对,怎么快速验证? —— 随便挑一个需求,说出要改哪几个文件。 说不出来就是分错了:要么文件散在四处,要么一个文件管了太多事。

设计定了,下一篇落数据库:五张核心表怎么连、明细表为什么要存两份冗余、 以及约束能拦住哪些错——三条都靠演示里的实测说话。

相关笔记