1. 项目概述:苍穹外卖DAY6这天我在做什么
先说结论:苍穹外卖这套实战项目,DAY6的核心内容其实是"把订单从用户手里接进来,再稳稳当当送出去"这条链路的第一个大节点——用户下单、支付回调、订单状态流转。如果你跟着课程或文档一路写到这里,前面几天大概已经搞定了员工端、分类管理、菜品管理这些后台CRUD,到了DAY6才真正进入C端核心逻辑,代码量和业务复杂度会明显上一个台阶。
DAY6这一天,我的实际工作可以拆成三块:
- 用户端接口:购物车、地址簿、下单这三个模块的接口设计,从DTO/VO定义到Controller、Service、Mapper全链路打通。
- 订单核心流程:创建订单(涉及主表+明细表+地址快照)、支付回调处理(这里用的是微信支付模拟流程)、订单状态自动更新。
- 定时任务初体验:使用Spring Task实现"超时未支付订单自动取消"和"完成订单自动派送"这两个定时任务。
如果你正在刷苍穹外卖,或者在做类似的电商/外卖类实战项目,这一天值得认真对待。因为订单表设计、状态机流转、事务边界处理这些东西,不只是这个项目要用的,放到任何带交易性质的系统里都一样是核心难点。
我印象最深的是:DAY6的代码量其实没有前面几天多,但调试时间反而翻倍。原因很简单——订单流程是个跨表、跨方法、跨模块的链路,任何一个环节的小疏忽(比如状态值写错、事务没生效、异步任务没触发),排查起来都会让你怀疑人生。
下面我把DAY6的完整思路、踩坑记录、代码关键点全部摊开讲,适合正在写这个项目的人对照参考,也适合想搞懂"外卖系统订单模块到底怎么设计"的人读。
2. 下单链路拆解:从购物车到订单落库到底经历了什么
2.1 数据模型设计:订单主表与明细表为什么必须分开
先说一个很多新手会忽略的问题:订单数据为什么要拆成order主表和order_detail明细表,而不是直接存一个JSON字符串了事?
答案很实在:一张表存全部字段,查询和统计会变得极其痛苦。比如你要查"这个月销售额是多少",如果所有商品都塞在订单表的一个字段里,那你得先把每条订单的商品数据解析出来再聚合,这不是查数据库,这是在写解释器。拆成主表和明细表之后,订单主表存一次交易的整体信息(订单号、金额、状态、收货地址、下单时间),明细表存这笔订单包含的每一种商品(商品名、数量、单价、小计),主表和明细表通过order_id关联。
DAY6的订单实体设计大致长这样:
- Orders主表:id、订单号、订单状态、下单时间、结账时间、支付状态、支付时间、订单金额、实收金额、收货人、电话、地址、备注、用户id、商家id等。
- OrderDetail明细表:id、订单id、商品id、商品名称、商品图片、商品单价、商品数量、商品小计。
要注意的是,明细表里除了商品id,还冗余保存了商品名称、商品图片、单价这些快照字段。这是因为商品信息后续可能被修改(改名、调价、下架),但历史订单必须保持下单那一刻的样子。这个思想叫"数据快照",在电商订单设计里是标配操作。
2.2 下单接口的完整调用链:Controller到Mapper的每一步
DAY6下单接口的核心逻辑,我画一条调用链出来你就明白了:
用户提交下单请求 → 接收下单DTO(包含地址簿id、支付方式、备注、预计送达时间等) → 校验参数合法性 → 根据用户id查询购物车列表 → 如果购物车为空,直接抛业务异常 → 构造订单主表数据(生成订单号、设置初始状态、计算总金额) → 批量插入订单明细数据 → 清空当前用户的购物车 → 封装并返回下单VO(包含订单号、实付金额等)这中间有几个关键设计点:
订单号生成。DAY6用的是时间戳+用户id后几位之类的拼接方式,但实际商用系统一般会用更复杂的规则(比如雪花算法、分段发号器),避免高并发下订单号冲突。做项目可以简单点,但要理解背后的原因。
金额计算。总金额不是前端传过来的,而是后端根据购物车里的商品单价和数量重新计算。前端传金额只作为一种展示参考,绝不能直接信任。你在DAY6代码里会看到类似amount = sum(price * number)的循环累加逻辑,这个非常重要。
状态初始值。下单成功时,订单状态置为"待付款"(状态值1),支付状态置为"未支付"(状态值0)。后面支付回调成功后才改成"待接单"和"已支付"。
事务边界。插入主表、插入明细、清空购物车这三步必须在一个事务里。如果主表插入成功但明细插入失败,最后购物车还被清了,那就真出大事了。DAY6在Service层加了@Transactional注解,但注意这个注解的失效问题,后面我会单独讲。
2.3 下单模块的DTO/VO设计:接口参数与返回值的边界
这里补充一个容易被忽略但非常实用的细节:DTO和VO各司其职,不要混用。
- DTO(数据传输对象):接收前端请求参数,比如
OrdersSubmitDTO包含地址簿id、支付方式、备注、预计送达时间。前端不需要传订单状态、订单号这些后端才能生成的东西。 - VO(视图对象):返回给前端的数据,比如
OrdersSubmitVO包含订单号、实付金额、预计送达时间等。后端内部使用的实体类不一定原样返回给前端,避免把内部字段泄露出去。
很多初学者图省事,直接从Controller接收实体类,然后把实体类原样返回。这在单体小项目里勉强能用,但一旦前后端联调、接口文档对接起来,你会发现自己被字段耦合死死绑住——前端多要一个字段你要改实体类,后端内部加个字段前端也会看到。DAY6用DTO/VO隔离之后,接口的稳定性和可维护性都会明显好很多。
3. 支付回调和订单状态流转:最容易翻车的一段流程
3.1 微信支付模拟流程:回调接口到底在回调什么
DAY6因为是在本地开发环境,接不了真实的微信支付,所以用的是模拟支付回调。但模拟不代表你可以把流程糊弄过去,相反,你更要把回调的机制理解透。
真实场景下,微信支付的大致流程是:
- 用户下单后,前端调用后端接口,后端向微信支付平台发起预下单,拿到一个支付链接或支付参数。
- 前端唤起微信支付,用户在微信里完成付款。
- 微信支付平台异步通知后端服务器(这就是回调),通知内容是订单号、支付结果、金额等。
- 后端在回调接口里校验签名、核对金额、更新订单状态。
- 同步给前端返回结果,前端轮询订单状态刷新页面。
DAY6的模拟逻辑则简单得多:前端在"支付成功"后直接调用后端的支付回调接口,后端收到请求后把订单状态从"待付款"改成"待接单",支付状态改成"已支付",同时记录支付时间。虽然少了签名校验、预付单这些真实环节,但状态流转的核心逻辑是一样的。
3.2 状态改变的事务与幂等性:为什么同一个回调不能执行两次
这里我要花点篇幅讲一个DAY6可能没有刻意强调、但实际开发中极其重要的东西——回调接口的幂等性。
假设用户支付成功了,微信支付平台回调了你的接口,你的接口执行了"更新订单状态为已支付"。一切正常。但如果因为网络抖动,回调通知发了两次,你的接口被调用两次,会发生什么?
如果代码是简单的:
order.setStatus(PAID); orderMapper.update(order);第二次回调会把状态又从"待付款"改成"已支付"吗?不会,因为第一次已经改成已支付了,第二次只是再改一次而已,看起来没问题。
但问题在于:如果回调里除了改状态,还有别的操作(比如通知商家、给用户发优惠券、记录支付流水),那第二次执行就会产生重复数据。
DAY6的解决方案是:在回调处理里先根据订单号查询当前状态,如果已经不是"待付款",直接return,不再重复处理。这个"先查再改"的防御性写法,就是幂等控制的最朴素实现。真实商用系统还会用数据库唯一约束、分布式锁、消息队列去重等方式加强,但思想是一样的。
状态机的设计也应该遵循"只能单向流转"原则:待付款 → 待接单 → 已接单 → 派送中 → 已完成,以及待付款超时 → 已取消。允许待付款直接跳到已完成,在业务上是说不通的。
3.3 顾客端查询订单状态:状态值映射与超时判断
订单支付完成后,前端需要轮询或下拉刷新查看订单状态。DAY6在顾客端订单查询这里,会做一件事——动态判断订单是否超时未支付,并实时更新状态。
这里有个常见误区:有人会把"修改超时未支付订单状态"寄托在前端判断上。比如前端拿到订单的时间,发现过了15分钟还没支付,就显示"已取消"。这不对。状态必须以后端为准,前端只是展示后端返回的状态值。
所以DAY6的做法是:
- 定时任务每天/每几分钟扫描所有"待付款"状态的订单。
- 如果下单时间距当前时间超过15分钟,就把状态更新为"已取消"。
- 在用户查询订单列表时,返回给前端的是实时计算后的状态,即后端在返回前先扫描更新一遍,确保用户看到的不是过期状态。
这个"先更新再查询"的思路,在订单这类时效敏感的业务里非常常见。你写代码的时候可以专门把"计算订单实际状态"这个方法单独抽出来复用,避免定时任务和查询逻辑各写一套导致状态不一致。
4. 购物车与地址簿:订单前置模块的细节打磨
4.1 购物车接口的四个动作:增、删、改、查
DAY6的购物车模块相对简单,但它是订单流程的入口,逻辑不能马虎。购物车核心接口有四个:
| 接口动作 | 请求方式 | 核心逻辑 |
|---|---|---|
| 加入购物车 | POST | 根据用户id+商品id查询是否已存在,存在则数量+1,否则新增记录 |
| 查看购物车 | GET | 按用户id查询全部商品,按创建时间倒序排列 |
| 修改购物车数量 | POST | 根据id和数量字段,要么加一要么减一,减到0就删除记录 |
| 清空购物车 | DELETE | 下单成功后调用,删除当前用户所有购物车记录 |
这里最值得琢磨的是"加入购物车"的去重逻辑。核心sql就是:
SELECT * FROM shopping_cart WHERE user_id = #{userId} AND dish_id = #{dishId}查到就setNumber(number + 1),没查到就新增一条记录,number默认为1。这个"先查再增"的逻辑虽然简单,但要注意并发问题——如果用户疯狂点击"加入购物车",两次请求同时进来,都查不到记录,就可能插入两条重复数据。DAY6这种学习项目一般不做额外处理,但你要知道真实系统里这里会加唯一索引或用数据库锁来防重。
4.2 地址簿的默认地址逻辑:每次下单都要注意的坑
地址簿模块本身不复杂,就是一个CRUD,但有一个点很值得注意——默认地址的设置逻辑。
DAY6里地址簿表的常见设计是加一个is_default字段,1表示默认地址,0表示非默认。新增地址时,如果用户还没有任何地址,第一条就自动设为默认地址;如果用户已经有默认地址,又把新地址设为默认,那原来的默认地址要取消掉。
我第一次写的时候就在这里踩坑了:直接写SQL把新地址is_default改为1,结果用户有了两个默认地址。后来改成两段式更新:
-- 先把该用户所有地址的默认标记清零 UPDATE address_book SET is_default = 0 WHERE user_id = #{userId}; -- 再把指定地址设为默认 UPDATE address_book SET is_default = 1 WHERE id = #{id};这两个操作同样要放在一个事务里,防止第一步成功第二步失败导致一个默认地址都没有。
另外,下单时地址是直接关联地址簿id,还是把地址快照存到订单表?
DAY6的具体做法我记得是:订单表冗余存储了收货人姓名、电话、详细地址这些字段,而不是只存一个地址簿id。这个设计和明细表冗余商品信息是一个道理——地址后续可能被用户修改,但订单必须保留下单那一刻的收货信息。如果只存地址簿id,用户下单后改了地址,你就不知道这份订单到底该往哪送了。
4.3 前端联调时的常见问题:字段缺失与类型不匹配
购物车和地址簿在DAY6属于偏前置的业务模块,前后端联调时最容易出现的问题是:
- 字段名不一致:后端VO里叫
amount,前端代码里用totalAmount,结果页面一直显示0。这种问题排查半天,结果就是两边字段名对不上。 - 金额类型丢失精度:数据库用decimal,Java用BigDecimal,但JSON序列化之后前端拿到的可能是数字,小数位被截断。DAY6里金额我建议统一保留两位小数,接口返回时格式化好,前端不要自己做四舍五入。
- null值问题:比如地址簿里没有设置默认地址的用户,
isDefault字段返回null,前端判断时直接null == 1,逻辑出错。
如果你在做前后端分离开发,建议定义一个统一的返回格式(比如Result类的code、msg、data),所有接口都走这个格式。遇到问题先打印接口返回的JSON,再看前端控制台报错,别上来就改代码。
5. 超时取消与自动派送:Spring Task定时任务实战
5.1 Spring Task入门:三行代码实现定时任务
DAY6的定时任务用的是Spring Task,不是Quartz。它最大的优点就是轻量,一个@EnableScheduling开启调度,一个@Scheduled注解定义执行规则,三行代码就能跑起来:
@Component public class OrderTask { @Scheduled(cron = "0 * * * * ?") // 每分钟执行一次 public void processTimeoutOrder() { // 查询超时未支付订单,改成已取消 } @Scheduled(cron = "0 0 1 * * ?") // 每天凌晨1点执行 public void processFinishedOrder() { // 查询派送中超过一定时间的订单,改成已完成 } }cron表达式里那个六个/七个字段的顺序,新手经常搞混。我个人的速记法是:秒 分 时 日 月 周。比如0 0 1 * * ?就是"每天1点0分0秒执行",0 * * * * ?就是"每分钟第0秒执行"。
5.2 定时任务里的SQL编写:状态+时间双重条件
定时任务本身不难,难在SQL条件怎么写。处理"超时未支付订单"的SQL大致是:
UPDATE orders SET status = 6, cancel_time = NOW(), cancel_reason = '超时未支付,系统自动取消' WHERE status = 1 AND pay_status = 0 AND order_time < DATE_SUB(NOW(), INTERVAL 15 MINUTE);注意三点:
- 必须同时限定status和pay_status,防止把已经支付但状态没来得及更新的订单误取消。
- 时间比较要用数据库时间或服务器时间,不要用代码里自己new的Date,保证多个定时任务实例之间不会因为时间不同步产生偏差。
- 扣掉15分钟要用
DATE_SUB而不是在代码里算好再传参,这样SQL可读性和维护性更好,也方便以后调整超时阈值。
"自动派送"的任务逻辑类似:
UPDATE orders SET status = 4 WHERE status = 3 AND estimated_delivery_time < NOW();也就是说,订单状态为"派送中"(3),但预计送达时间已经过了,就自动改成"已完成"(4)。当然真实业务里派送完成应该由骑手端确认,定时任务只能作为兜底策略,防止状态长期卡住不更新。
5.3 定时任务偶发性失效:排查思路与注意事项
DAY6里如果定时任务没有生效,我的排查顺序是这样的:
- 启动类有没有加
@EnableScheduling。这个注解漏掉,@Scheduled全部不生效,而且不报错,很容易漏。 - 任务类有没有被Spring扫描到。
@Component没加,或者包扫描范围不对,任务类压根没进容器。 - cron表达式是否正确。比如把
0 * * * * ?写成* * * * * ?,任务就不是每分钟执行一次,而是每秒执行一次。这种写法在开发环境危害不大,但部署到生产环境会影响数据库压力。 - 任务是否抛异常中断。Spring Task默认情况下,如果任务执行抛出异常,任务调度线程可能会被阻塞,后续任务不再执行。最好在定时任务方法内部try-catch,或者单独做一个异常记录表,方便排查。
另外一个容易被坑到的点:多个定时任务同时执行时,默认是单线程串行执行。如果一个任务卡住了(比如SQL锁等待),另一个任务也会被拖住。如果你的项目到了后面有多个定时任务,建议配置一个简单的线程池,让任务并行执行:
@Configuration public class SchedulingConfig { @Bean public TaskScheduler taskScheduler() { ThreadPoolTaskScheduler scheduler = new ThreadPoolTaskScheduler(); scheduler.setPoolSize(5); return scheduler; } }这个配置在DAY6里可能用不上,但你知道有这么回事就行,以后做更复杂项目会用到。
6. 事务与并发:DAY6里最容易被忽视的隐藏地雷
6.1 @Transactional的五种失效场景
DAY6的下单方法加了@Transactional,但注解加了不等于事务就生效。我盘点一下最常见的失效场景,你照着检查自己的代码就能避开一大半问题:
- 方法被private修饰。Spring事务是通过动态代理实现的,只有通过外部调用public方法时,事务增强才会生效。同类内部调用private方法,事务直接失效。
- 类没有被Spring管理。即类上没加
@Service、@Component等注解,Spring不会创建代理对象,自然没有事务。 - 方法内部自调用。同一个类里,方法A调用方法B,B上有
@Transactional,但A没有,事务不会生效。因为调用发生在对象内部,不走代理。 - 异常被try-catch吞掉。事务默认只在RuntimeException时回滚,如果你自己catch了异常不往外抛,事务框架感知不到异常,就会提交。
- 数据库引擎不支持事务。比如MySQL的MyISAM引擎就没有事务支持,要用InnoDB。
DAY6的下单方法我建议这样写,能避开大部分雷区:
@Service public class OrderServiceImpl implements OrderService { @Transactional(rollbackFor = Exception.class) public OrderSubmitVO submit(OrdersSubmitDTO dto) { // 1. 校验购物车 // 2. 插入订单主表 // 3. 插入订单明细 // 4. 清空购物车 } }注意rollbackFor = Exception.class这个属性。默认情况下@Transactional只回滚RuntimeException,如果你代码里抛的是CheckedException,事务不会回滚,这会让数据处于半写状态。显式声明回滚所有异常是最稳妥的。
6.2 高并发下单场景:超卖与重复下单问题
DAY6是单体学习项目,一般不会刻意模拟高并发。但你既然学到了订单模块,这些东西迟早要面对:
- 超卖问题:商品库存只有1件,两个用户同时下单成功,库存变成-1。解决思路:数据库乐观锁
UPDATE stock SET stock = stock - 1 WHERE id = ? AND stock > 0,利用受影响行数判断是否成功。 - 重复下单:用户手抖点了两次提交,生成了两笔订单。解决思路:前端按钮置灰防止连点,后端可以加防重令牌,或者在下单接口做幂等校验(比如用用户id+购物车hash判断短时间内是否重复提交)。
这些内容DAY6不一定要求实现,但面试时很容易被问到,提前理解不吃亏。
6.3 事务边界到底该划在哪:下单方法里能做多余的操作吗
一个比较好的实践原则是:一个事务方法只做和当前业务强相关的数据变更,外部调用、异步任务、网络请求不要放在事务里。
比如DAY6里下单成功后需要清空购物车,这个清空操作和下单强相关,放在同一个事务里没问题。但假设下单成功之后要调外部短信接口通知用户,那我建议把短信调用放到事务方法外面。因为外部接口可能超时、可能失败,如果放在事务里,会让数据库连接一直被占用,事务长时间不提交,拖垮系统性能。
你以后做项目,如果发现某个接口响应特别慢,先检查一下是不是在事务里做了太多跟数据库无关的事情,比如发邮件、调第三方接口、解压文件。这些操作耗时越长,事务占用的数据库连接就越久。
7. 实战踩坑记录:我这一天调试最久的三件事
7.1 坑一:购物车清空了,订单却没生成
现象:下单接口报错,回滚了,但后面查购物车发现居然是空的。一开始我很懵,逻辑顺序明明是插订单、插明细、清空购物车,订单失败应该回滚,购物车不会被清。
排查过程:打印日志,发现走的不是回滚路径,而是购物车清空先执行了。后来一查,是因为我在Controller里先调用了"清空购物车"接口(因为前端下单流程中先请求了清空购物车,再请求下单),两个接口之间有先后时序,导致下单失败但清空操作已经被另一个事务提交了。
教训:前端调用顺序一定要和后端接口设计对齐。下单接口内部自带清空购物车逻辑,前端就不要额外调一次清空接口,否则会出现重复操作和事务边界混乱。
7.2 坑二:回调接口被重复调用,订单状态错乱
现象:我本地调试时,用工具连续触发两次支付回调,结果订单状态直接跳过了"待接单",变成了"派送中"。这就是典型的状态机没有做单向流转校验。
修复方式是在回调方法第一步就拦截:
Orders order = orderMapper.getById(orderId); if (order == null || !order.getStatus().equals(WAIT_TO_ACCEPT)) { log.warn("订单状态异常,回调处理跳过:{}", orderId); return; }后来我还加了个判断:如果订单已经被取消,回调也不能把订单改成已支付。在真实支付回调里,已取消订单的支付结果要做退款处理,这里不展开,但你要有这个意识。
7.3 坑三:定时任务没触发,以为是代码问题,结果是cron写错了
现象:超时未支付订单过了20分钟还是"待付款",定时任务像没跑一样。
排查过程:先检查启动类注解,没问题;检查任务类扫描,没问题;最后把cron表达式拿出来一行一行看,发现我写的是:
0 0 15 * * ? // 我以为这是“每15分钟执行一次”但按照cron语法,这个表达式其实是"每天15点0分0秒执行一次"。那当然不会频繁触发。正确的"每15分钟执行一次"应该写成:
0 0/15 * * * ?说实话,cron这东西不看文档纯靠自己记,真的容易翻车。我建议在写完之后用在线cron表达式解析工具先验证一遍,别直接部署。
8. 订单模块的扩展思考:DAY6之外还能做哪些优化
8.1 从"能用"到"好用":状态机枚举化
DAY6的订单状态大概率是用数字常量写到代码里的,比如1代表待付款、2代表待接单。项目能跑,但代码可读性很差——你看到order.getStatus() == 1,得去翻注释才能知道1是什么意思。
我建议自己动手把它重构为枚举:
public enum OrderStatus { WAIT_PAY(1, "待付款"), WAIT_ACCEPT(2, "待接单"), ACCEPTED(3, "已接单"), DELIVERING(4, "派送中"), COMPLETED(5, "已完成"), CANCELED(6, "已取消"); private final Integer code; private final String desc; // 构造方法、getter }改完之后,if (order.getStatus().equals(OrderStatus.WAIT_PAY.getCode()))这种写法一目了然,后期扩展状态流转逻辑也方便。
8.2 从"同步"到"异步":下单成功后的事件通知
DAY6的下单、支付、接单都是同步调用。真实项目中,订单状态变化往往需要通知多个下游系统(比如通知商家、通知骑手、推送消息给用户),这些操作如果全部同步执行,接口响应会越来越慢。
优化思路是引入消息队列,下单成功后发布一个"订单已创建"事件,由消费者异步处理后续操作。这个用Spring自带的ApplicationEvent也能做个简易版,不需要依赖外部中间件,适合自己练手加深理解。
8.3 从"单机"到"分布式":订单号的全局唯一策略
DAY6的订单号生成方式不提性能的话问题不大,但对接支付平台后,订单号在某些场景下会有严格约束(比如微信支付要求商户订单号不能重复)。以后做分布式部署时,订单号生成就要考虑使用全局唯一ID方案。
雪花算法是现在比较主流的方案,它通过时间戳+机器id+序列号保证每秒能生成大量不重复ID,而且趋势递增,对数据库索引友好。这个现在不用写,但你在简历上写"熟悉订单号生成策略"的时候,至少能说出几种方案和它们的优缺点。
9. 写在最后的几点体会
DAY6这一天的工作量和前五天相比,看起来只是多了一个"订单模块",但实际上是从"管理后台"跨入"用户交易"的关键转折点。这一天里我踩过的坑、修正过的思路,有几个体会想留给你:
第一,设计表结构时多留一个心眼。订单主表和明细表的拆分、快照字段的冗余,这些设计看起来是"多写几个字段"的小事,但到了统计报表、数据核对、问题追溯的时候,你就会感谢当初的冗余设计。少一个快照字段,等上线之后想补,数据已经丢了。
第二,状态流转一定要画清楚再做。我见过很多项目在开发阶段不重视状态机,等到联调阶段才发现"已取消的订单还能被接单""已完成的订单还能退款",那时候改代码就要动一堆地方。DAY6虽然只有几个状态,但你可以在纸上画一画每个状态的来源和去向,心里有数再动手写。
第三,调试时不要死盯代码,先把数据链路串一遍。我调支付回调那天卡了很久,后来发现不是代码逻辑的问题,而是订单状态在数据库里已经被之前的测试数据污染了。把数据库里的脏数据清掉、重新走一遍完整流程,比反复打断点有效率得多。
第四,做项目不要满足于"跑通"。如果仅仅把接口调通、页面能点,那DAY6的价值只发挥了三分之一。把事务失效的场景挨个测一遍,把定时任务的时间调整几次看效果,把状态枚举重构一遍,这些主动做的事,才是拉开差距的地方。
苍穹外卖这个项目虽然是个教学项目,但订单模块的设计思路和坑点,放到真实系统里一样适用。我写完DAY6之后最大的感受是:外卖系统的难度不在于某个技术点有多深,而在于业务链条太长、状态太多、分支太杂。把这些理顺了,后面再做别的业务系统,会顺很多。