最近我把一个基于Spring Boot的“锦宇气体城市货运系统”从零到一完整做了一遍。这个选题原本是计算机毕设常见的方向,但如果你只是按“增删改查”糊弄过去,那就浪费了它背后一整套业务逻辑。锦宇气体是一家做工业气体城市配送的公司,客户是工厂、医院、实验室,配送物品是氧气、氮气、氩气、乙炔这类的气瓶。这类业务不像普通快递,气体有安全属性,气瓶要跟踪,订单要调度,配送完成还要回单签收。所以这个系统核心就是:订单怎么进来、车辆怎么调度、气瓶怎么流转、签收怎么闭环。整条链路捋通,就是一个完整体面的毕设项目。
这篇文章我会把整个项目的设计思路、技术选型、数据库建模、关键代码实现和坑点全部拆开讲清楚。适合正在做Spring Boot毕设的同学,也适合想用真实业务场景练手的开发者。你不用照着我的代码抄,但可以把这个业务模型当作参考,套到你自己的题目里。
1. 项目到底在做什么:需求拆解与系统边界
1.1 气体城市货运的业务痛点与系统定位
先别急着写代码,把业务搞清楚比任何技术都重要。气体城市货运和普通快递配送最大的区别在于“货”的特殊性。以锦宇气体为例,它配送的工业气体钢瓶属于压力容器,每一瓶都有出厂编号、检验日期、充装记录,而且客户下单往往不是买一个瓶子,而是买“一瓶充好气的瓶子”,用完以后旧瓶还要回收。也就是说,系统里要同时管理订单、气瓶档案、配送任务和回瓶记录。
这个定位想清楚之后,系统就不是一个简单的“订单管理系统”,而是一个轻量级的业务闭环系统。它要解决的核心问题有三个:第一,客户打电话或在小程序下单后,业务员能快速生成销售订单,不用Excel来回传;第二,调度员能根据订单地域分布、车辆在途状态,把订单合并成配送单,指派给司机;第三,司机完成配送后,客户签收信息能实时回传,气瓶的去向能查得到。
所以我的项目定位是一个“给中小型气体贸易公司使用的内部运营系统”,目标用户是业务员、调度员、司机、财务和管理员。不需要做C端商城那样复杂的营销功能,但业务流程必须完整、状态必须可追踪。这个边界定下来,后面所有的表结构和接口设计就都有依据了。
1.2 角色划分与核心用例
基于这个定位,我把系统划分为四类角色。普通用户(业务员)负责录入订单、维护客户信息;调度员看订单池,把待配送订单组合成配送单,指派车辆和司机;司机端我做了简化,用Web页面模拟移动端操作,司机登录后能看到自己的配送任务,点击“出发”、“送达”,上传签收情况;管理员负责用户管理、数据统计和基础数据维护。
核心用例围绕订单生命周期展开。我整理了五个关键流程:客户下单、订单审核、调度派车、配送执行、签收归档。客户下单可以由业务员代录,也可以预留接口给小程序;订单审核是为了防止超卖或余额不足;调度派车是整个系统的核心,涉及订单合并、车辆匹配;配送执行和签收归档则把“货”和“单”全部闭环。
这几个用例画成用例图其实很简单,但你要能讲清楚为什么需要每个环节。比如“订单审核”这个步骤,很多毕设项目会省掉,但气体配送涉及安全责任,订单必须确认气瓶库存足够才能派车。你多设计一个审核状态,答辩时就能顺手讲出业务合理性。
1.3 功能清单与优先级
功能清单我分成三档。第一档是核心功能,必须完整实现,包括登录认证、客户管理、气瓶档案管理、订单管理、配送单管理、签收回单。第二档是增强功能,包括车辆管理、司机管理、路线统计、订单看板。第三档是拓展功能,包括报表导出、短信通知、小程序下单接口,有时间再做。
我自己定的优先级是:先跑通“订单-调度-签收”这条主链路,再去补统计和报表。很多同学做毕设喜欢先做权限管理,再做一堆基础资料的增删改查,最后发现核心业务流程没时间做,这是大忌。记住,答辩老师最想看到的不是你有多少张表,而是你能不能把一条完整业务线讲清楚。
2. 技术选型:为什么是Spring Boot这套组合
2.1 用Spring Boot做后端的真实理由
Spring Boot在这类毕设项目里几乎是标配,但你要能说出为什么,而不是“大家都在用”。我的理解是,它帮你把以前Spring MVC项目里大量繁琐的XML配置全部自动化了。就拿这个项目举例,我需要内嵌Tomcat、需要自动配置数据源、需要快速集成MyBatis-Plus,这些在Spring Boot里只需要在pom.xml里加依赖,然后写几行配置就行。
另外一个好处是它非常适合“前后端分离 + 单体部署”的毕设形态。我不需要搭微服务,不需要注册中心,一个Spring Boot应用就能承载所有后端接口,前端用Vue或原生页面都能对接。打包成jar包后,在一台机器上就能跑起来,演示环境非常好搭。
我建议用Spring Boot 2.7版本,而不是最新的3.x。原因是我实测下来,3.x对JDK版本、部分第三方库的兼容性要求更严格,很多网上的教程和现成依赖都是基于2.x。毕设求稳,用2.7加JDK 8或11最不容易踩版本坑。
2.2 配套技术栈的选型和理由
后端除了Spring Boot,我选了MyBatis-Plus作为持久层框架。选它不是因为流行,而是它把单表的增删改查简化到了极致,比如根据ID查询、分页查询、条件构造器都不用写XML,这能帮我把大量时间节省到业务逻辑上。同时它对复杂SQL又保留了手写的空间,不会像JPA那样在复杂查询时让人头疼。
数据库用MySQL 5.7或8.0都可以,这个项目表结构不复杂,InnoDB引擎就够。权限认证我用的是Sa-Token,比Spring Security轻量很多。Spring Security学习成本高,配置一堆SecurityFilterChain,对毕设来说太重;Sa-Token只需要登录后给前端发一个token,然后通过拦截器校验就行了,半天就能搞定。
前端方面,如果图省事,直接用Thymeleaf模板加Bootstrap,服务端渲染,不用跨域,部署最简单。不过我这次为了页面好看,用了Vue 3加Element Plus,通过axios调后端接口。这个组合不冲突,Spring Boot只负责JSON接口,前端页面单独打包放到静态目录下,照样一个jar包搞定。
2.3 项目结构规划
项目结构我按照常见的分层思路来,核心是清晰。包名用com.jinyu.gas,下面分成controller、service、mapper、entity、dto、common。common里放统一返回结果类、异常处理器、常量定义。controller只做参数接收和响应,业务逻辑全部放在service层,mapper只访问数据库。
这样做最大的好处是代码好讲。答辩的时候,老师随便点一个方法,你都能说清楚“这里Controller只做了参数校验,具体的订单状态计算在Service里”。而且后期维护也方便,比如要加一个“订单作废”操作,不需要动Controller,直接在Service里加方法就行。
3. 核心设计与数据库建模
3.1 实体关系梳理
数据库是整个项目的灵魂,我反反复复改了三版才定下来。核心实体包括:用户、角色、客户、气瓶、订单、订单明细、配送单、配送单明细、签收记录、车辆、司机。
关键关系是这样的:一个客户可以有多张订单,一张订单包含多个气瓶明细;调度员把多张订单合并到一张配送单,一辆车对应一张配送单,一个司机对应一张配送单;司机完成配送后,针对配送单生成签收记录,同时更新气瓶状态。
这里最容易出错的是订单和配送单的关系。一开始我设计成“订单表里加一个配送单ID”,后来发现一个订单里的气瓶可能被拆到两辆车上,比如客户一次下单10瓶氧气,仓库只有6瓶,先送6瓶,剩下4瓶再派另一辆车。所以我把订单和配送单设计成多对多,通过配送单明细表关联,这样拆单、合单都能支持。这个细节非常加分,答辩时可以重点讲。
3.2 数据库表结构详解
我整理一下核心表的设计思路。每张表都加了create_time、update_time这两个公共字段,用MyBatis-Plus的自动填充功能维护。
订单主表order_main主要字段包括:order_no(单号)、customer_id、total_amount、order_status、remark。气瓶明细表order_item包含order_id、gas_bottle_id、gas_type、quantity、unit_price。注意气瓶明细表和订单强关联,一条订单记录对应对条明细。
配送单表delivery_main包含:delivery_no、vehicle_id、driver_id、delivery_status、start_time、end_time。配送单明细表delivery_item包含delivery_id、order_id、order_item_id、actual_quantity。这张表是拆单合单的关键,它不直接存气瓶,而是存“订单里的明细项”。
气瓶表gas_bottle包含bottle_no、gas_type、capacity、bottle_status(在库/在途/已回收)、last_fill_time。签收表receipt包含delivery_id、customer_sign_person、sign_time、sign_remark、return_bottle_count。
字段命名统一用下划线,避免使用关键字作为表名。比如不要用order作为表名,我改成order_main,省得SQL里到处加反引号。
3.3 状态流转设计
状态流转是最能体现系统设计能力的部分。订单状态我定义成:待审核(0)、已审核待调度(1)、已调度(2)、配送中(3)、已完成(4)、已取消(5)。配送单状态定义成:待派车(0)、待出发(1)、配送中(2)、已完成(3)。
这两个状态必须联动。订单从“已审核待调度”变成“已调度”,是在配送单生成的时候批量更新;配送单变成“配送中”时,订单状态同步变成“配送中”;配送单完成后,订单状态变成“已完成”。我建议不要散落地在业务代码里写状态值,而是定义常量类或者枚举,避免魔法数字。
这里有一个很实用的技巧:用状态机思路来约束操作。比如“待审核”的订单不允许直接取消?可以允许,但“配送中”的订单不允许取消。我在Service层统一做一个checkOrderStatusChange方法,每次变更前先校验,校验不通过直接抛业务异常。实测下来,这个校验能挡掉80%的脏数据问题。
4. 关键模块实操:从0到1实现核心流程
4.1 搭建Spring Boot工程
我用Spring Initializr或直接用IDEA创建项目,选择Spring Boot 2.7、Java 8,依赖勾选Web、MySQL Driver、Lombok。创建完以后,pom.xml里再加MyBatis-Plus和Sa-Token的依赖。
<dependency> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus-boot-starter</artifactId> <version>3.5.3.1</version> </dependency> <dependency> <groupId>cn.dev33</groupId> <artifactId>sa-token-spring-boot-starter</artifactId> <version>1.34.0</version> </dependency>配置文件application.yml我习惯这样写。注意数据库密码不要写在代码里,可以用环境变量占位。本地调试时再回填。
server: port: 8080 spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/jinyu_gas?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai username: root password: ${DB_PASSWORD:123456} mybatis-plus: configuration: map-underscore-to-camel-case: true log-impl: org.apache.ibatis.logging.stdout.StdOutImpl global-config: db-config: logic-delete-field: deleted logic-delete-value: 1 logic-not-delete-value: 0启动类加上@MapperScan注解,指向mapper包。然后写一个简单的健康检查接口,确认项目能跑通,再继续后续开发。很多人一上来就写几十个接口,结果启动都报错,不如先把空工程跑起来。
4.2 订单模块实现:Controller/Service/Mapper三层
订单模块是整个系统的入口,我拆成三层来实现。先看实体类,用MyBatis-Plus的注解标识主键为自增ID。
@Data @TableName("order_main") public class OrderMain { @TableId(type = IdType.AUTO) private Long id; private String orderNo; private Long customerId; private BigDecimal totalAmount; private Integer orderStatus; private String remark; private LocalDateTime createTime; private LocalDateTime updateTime; }Mapper接口就是一个简单的继承,不用写任何SQL。
public interface OrderMainMapper extends BaseMapper<OrderMain> { }Service层写具体的业务逻辑。创建订单时,先生成订单号,再插入主表,然后循环插入订单明细。整个过程加@Transactional,因为主表和明细表必须同时成功,否则就会有一条孤立的订单没有明细。
@Service public class OrderServiceImpl extends ServiceImpl<OrderMainMapper, OrderMain> implements OrderService { @Override @Transactional(rollbackFor = Exception.class) public void createOrder(OrderCreateDTO dto) { OrderMain order = new OrderMain(); order.setOrderNo(generateOrderNo()); order.setCustomerId(dto.getCustomerId()); order.setOrderStatus(OrderStatusEnum.PENDING_AUDIT.getCode()); this.save(order); List<OrderItem> items = dto.getItems().stream() .map(item -> { OrderItem oi = new OrderItem(); oi.setOrderId(order.getId()); oi.setGasBottleId(item.getGasBottleId()); oi.setGasType(item.getGasType()); oi.setQuantity(item.getQuantity()); return oi; }).collect(Collectors.toList()); orderItemService.saveBatch(items); } }Controller层只接收DTO,返回统一结果对象。我用Result类包装,前端看到code为200就是成功,否则把message弹出来。
@RestController @RequestMapping("/api/order") public class OrderController { @PostMapping("/create") public Result<Long> create(@RequestBody OrderCreateDTO dto) { orderService.createOrder(dto); return Result.success(); } }这里有个经验:更新操作不要直接把实体类丢给前端,一定要用DTO。因为实体类里可能有数据库自增字段、逻辑删除字段,直接拿DTO去接收JSON会造成字段混淆,也容易有安全隐患。
4.3 调度派车的核心逻辑
调度是系统里最有“算法感”的部分,虽然我没做复杂算法,但合并订单的逻辑足以讲出水平。我先查询所有状态为“已审核待调度”的订单,再按客户所在区域分组,然后把同一个区域的订单组合成一张配送单,指定一辆车和一个司机。
这个逻辑用代码写出来就是:
public void dispatch(DispatchDTO dto) { // 1. 生成配送单主记录 DeliveryMain delivery = new DeliveryMain(); delivery.setDeliveryNo(generateDeliveryNo()); delivery.setVehicleId(dto.getVehicleId()); delivery.setDriverId(dto.getDriverId()); delivery.setDeliveryStatus(DeliveryStatusEnum.PENDING_DEPARTURE.getCode()); deliveryService.save(delivery); // 2. 建立配送单与订单明细的关联 List<DeliveryItem> items = new ArrayList<>(); for (Long orderId : dto.getOrderIds()) { OrderMain order = orderService.getById(orderId); if (order == null || order.getOrderStatus() != OrderStatusEnum.AUDITED.getCode()) { throw new BizException("订单不存在或不在待调度状态"); } DeliveryItem item = new DeliveryItem(); item.setDeliveryId(delivery.getId()); item.setOrderId(orderId); items.add(item); } deliveryItemService.saveBatch(items); // 3. 联动更新订单状态 orderMapper.updateOrderStatusByIds(dto.getOrderIds(), OrderStatusEnum.DISPATCHED.getCode()); }我强烈建议在调度前做数据校验,不要光写查询。这里至少校验两件事:车辆是否空闲,订单是否存在且状态正确。车辆空闲状态可以简单用“车辆状态字段”判断,更严谨的做法是查一下该车辆是否还有状态为“配送中”的配送单。这个校验就能避免一辆车同时被派两单。
状态联动更新我用了一个自定义SQL,写在XML里:
<update id="updateOrderStatusByIds"> update order_main set order_status = #{targetStatus} where id in <foreach collection="ids" item="id" open="(" separator="," close=")"> #{id} </foreach> and order_status = #{sourceStatus} </update>后面加一个and order_status = #{sourceStatus},是防止并发情况下把一个已经配送中的订单再次更新成待出发。虽然毕设项目并发量不大,但你写出这个细节,老师会觉得你有工程意识。
4.4 签收回单与气瓶状态更新
司机端流程是登录后查看配送单列表,点击“送达”跳转到签收页面。签收页面里能看到这辆车上所有气瓶的编号,司机勾选实际签收数量、填写回瓶数量、上传签收人姓名。提交后,后端做三件事:更新配送单状态为已完成、更新关联订单状态为已完成、更新气瓶状态。
更新气瓶状态时需要小心。一个气瓶签收后,如果客户没有回瓶,那气瓶状态应该变成“在客户处”,等下次回收;如果有回瓶,那状态变成“已回收”。我设计了一个简单策略:前端提交回瓶数量,后端把回瓶的气瓶状态改成已回收,未回瓶的改成在客户处。不要试图用复杂逻辑自动判断,毕设项目里手动维护一个状态字段完全够用。
签收接口我用事务包裹,因为涉及多张表的更新。核心代码示意如下:
@Transactional(rollbackFor = Exception.class) public void signReceipt(ReceiptDTO dto) { DeliveryMain delivery = deliveryService.getById(dto.getDeliveryId()); if (delivery == null || delivery.getDeliveryStatus() != DeliveryStatusEnum.DELIVERING.getCode()) { throw new BizException("配送单不存在或不在配送中状态"); } // 1. 保存签收记录 receiptMapper.insert(dto.toEntity()); // 2. 更新配送单状态 delivery.setDeliveryStatus(DeliveryStatusEnum.COMPLETED.getCode()); delivery.setEndTime(LocalDateTime.now()); deliveryService.updateById(delivery); // 3. 更新订单状态 orderMapper.updateStatusByDeliveryId(dto.getDeliveryId(), OrderStatusEnum.COMPLETED.getCode()); // 4. 更新气瓶状态 gasBottleService.updateStatusByDelivery(dto.getDeliveryId(), dto.getReturnBottleIds()); }整套流程跑完后,数据库里的数据就能串起来了:客户下的订单,变成配送单,司机签收,气瓶状态变化,所有表都有据可查。做到这一步,主业务闭环就完整了。
5. 部署、测试与踩坑记录
5.1 本地运行、前后端联调与打包
我建议开发时用前端代理解决跨域问题,后端不单独配CORS。Vue项目在vite.config.js里配置proxy,把/api开头的请求转发到localhost:8080。这样后端只负责处理业务,不用每天跟跨域纠缠。
export default defineConfig({ server: { proxy: { '/api': { target: 'http://localhost:8080', changeOrigin: true } } } })后端打包前,先跑一遍单元测试或者用Postman把核心接口过一遍。打包命令很简单:mvn clean package -DskipTests,打完以后target目录下会生成一个jar文件。如果你用的是Vue前端,先把前端代码build一遍,把dist目录下的静态文件复制到后端src/main/resources/static目录下,再一起打包,这样整个系统只有一个jar包。
我遇到过一个坑:前端build后的静态资源路径是绝对路径/dist,后端访问时总是404。后来把vue.config.js的publicPath改成相对路径'./',问题就解决了。如果你用Vite,就在vite.config.js里配base: './'。这个问题很常见,但不查半天真的不会想到。
5.2 常见问题排查表
我把项目过程中踩过的坑整理成一张表,希望对你有用。
| 问题现象 | 可能原因 | 解决办法 |
|---|---|---|
| 启动报错Failed to configure a DataSource | 没有配置数据库连接,或者依赖里多了数据源自动配置 | 检查application.yml里的url、username、password,或者排除DataSourceAutoConfiguration |
| 插入数据时id为空 | 实体类主键没有加@TableId注解,或设置成IdType.INPUT | 加@TableId(type = IdType.AUTO),保证数据库主键自增 |
| 前端请求接口报403 | Sa-Token默认拦截了所有请求,没有配置放行 | 在Sa-Token配置里把/login接口和静态资源加入排除列表 |
| 查询结果字段全是null | MyBatis-Plus驼峰映射没开启,或者实体类字段与表字段不一致 | 配置map-underscore-to-camel-case: true,或者用@TableField指定字段名 |
| 打包后前端页面白屏 | 前端静态资源路径不对 | 把publicPath或base设置为'./',重新build再复制到static目录 |
| 时间字段差了8小时 | 数据库连接串没有设置serverTimezone | url末尾加serverTimezone=Asia/Shanghai |
最后一条时间字段的问题很多人都会遇到,实际上只要在连接串上加了时区,基本能一次解决。另外,实体类的时间字段建议用LocalDateTime,不要用java.util.Date,否则JSON序列化出来的格式比较难看,还得额外写格式化配置。
5.3 答辩准备:如何把项目讲出亮点
代码写完只是第一步,答辩才是把这套系统的价值真正展现出来的地方。我的经验是,围绕“业务闭环”而不是“技术功能”来组织讲解逻辑。开门见山告诉老师,这是一个面向城市气体配送的业务管理系统,核心解决的是订单、调度、签收的协同问题。这句话就比“我做了一个增删改查项目”要有吸引力得多。
准备两张图,一张就是业务流程图,另一张是数据库关系图。不用花哨,但要能讲清每个表之间为什么这样关联,尤其重点讲订单和配送单的多对多拆分逻辑。老师大概率会问:为什么配送单明细表里存的是order_item_id,而不是gas_bottle_id?你要回答:因为一个订单明细可能拆到多个配送单,存明细ID才能追溯清楚每个订单的履约情况。
技术部分准备好三个“为什么”:为什么用Spring Boot,为什么用MyBatis-Plus,为什么状态要联动更新。每个问题都能答出上面的理由,答辩基本就稳了。还有一个加分项:主动说“我目前状态字段用的是常量类,后续可以改成枚举或状态机模式来进一步规范操作”,这句话比你自己吹十句都管用,因为它显示了你有扩展思维。
6. 这套系统后续还能往哪儿扩展
这个项目做完以后,我其实又想了几个方向。第一个是给系统加一个“气瓶库存预警”。现在气瓶状态是分开维护的,完全可以在每次签收后统计一下“在库”的气瓶数量,低于安全库存就在首页做一个预警提示。这个功能不复杂,但一加进去,系统就从“记录工具”变成了“管理工具”,档次完全不一样。
第二个是订单自动拆单。现在我是手动选择哪些订单放进同一张配送单,后续可以做一个简单的启发式算法,根据订单地址的经纬度距离和车辆载重,自动推荐配送单。不需要做太复杂,能按区域分组、按载重上限拆分就够了,加上这个就很好讲了。
第三个是数据统计可视化。用ECharts做一个驾驶舱页面,把本周订单量、配送完成率、每个气瓶类型的出库量统计出来。这个对毕设展示特别加分,因为页面一出来就很直观,答辩时老师不用听你讲代码,一眼就能看出系统价值。
扩展方向不要贪多,加一两个自己能掌控的就行。关键是每个扩展点都要围绕现有系统来展开,不能凭空说一些“接入大数据”、“上微服务”之类的大话。踏踏实实把一个业务闭环做好,再往前想半步,就已经超过大多数学生项目了。
我个人做完这个项目的体会是,Spring Boot本身只是一个工具,真正值钱的是你对自己要做的业务理解到不到位。你先把气体配送这件事怎么运转想清楚了,代码只是用手敲出来的结果而已。如果读者里有人正在做类似的毕设题目,希望这篇内容能帮你在动手前把系统边界、核心流程和关键表结构想明白,别一上来就闷头写代码。