先说个真实场景。做过校车调度相关系统的朋友应该都有感触,这东西看起来简单,真正落地时全是细活。司机要排班、车辆要保养、路线要绕开修路路段、家长要确认孩子上车、作业拍照记录还要留痕。我手头这套基于SpringBoot+Vue的校车调度管理系统,就是冲着这些琐碎但必须管好的事去的。它把车辆信息、司机档案、路线站点、班次计划、乘车记录和后台权限全部串在一起,调度员在一个后台里就能完成排班、改线、核验和报表导出,司机侧可以查看当天任务,管理侧能追溯每一次乘车记录。
这篇文章主要写给三类人看:做毕业设计或者课程设计的同学,想直接借鉴一个可运行的全栈项目;公司内部需要快速搭调度类管理系统的开发人员;以及真正在校车运营场景里负责排班调度的业务人员。技术栈是Java生态里很常见的SpringBoot加MyBatis,前端用Vue,数据落在MySQL里。整套系统从数据库设计到接口开发、前端页面、部署联调,我会把关键实现细节和踩过的坑都展开讲,不绕弯子。
1. 项目需求拆解与整体设计思路
1.1 校车调度到底在调度什么
很多人第一次接触这个项目,容易把目光全部放在车辆上,觉得管好车辆就等于做好了调度。实际上车辆只是整个业务链中的一个环节。我梳理过真实运营场景,调度员每天要回答的问题大概集中在四类:明天哪辆车跑哪条线,哪个司机负责,车目前是空闲、在修还是已经出车,以及每个站点大概什么时候有学生上下车。
也就是说,系统的核心管理对象是四个:车辆、司机、路线、班次。车辆解决运力资源的问题,司机解决人员排班的问题,路线解决线路和站点的问题,班次则是把前三者绑定在一起的具体运行计划。比如“周一早上6:40,车牌尾号003的校车,A司机,跑第2号线,7:10到第三站”,这一条描述就是一个典型的班次记录。
在设计系统时,我没有把四个对象做成孤立的增删改查页面,而是让它们之间产生关联和约束。车辆不能同时出现在两个时间冲突的班次里,司机不能一天被排到两条重叠的线路,路线的站点顺序也不能随意打乱。这个“约束感”才是调度系统区别于普通信息管理系统的关键。
1.2 业务角色的划分与权限边界
系统里我规划了两种主要角色:系统管理员和调度员,另外还有一类只读性质的查询角色,可以给到学校后勤或者车队负责人,便于他们盯数据,但没权限做修改操作。
管理员主要负责基础数据的维护,包括新增车辆、录入司机、维护路线站点、分配系统账号。调度员的工作偏向日常作业,比如创建第二天的班次计划、临时调整某条线路的出发时间、查看某个班次的乘车记录。查询角色就简单了,只能看仪表盘和各类报表,不能动任何配置。
这样的角色划分不只是为了权限好看,更重要的是防止误操作。实际运营中经常出现一个情况:有老师顺手删掉了一条旧路线,结果第二天的排班全乱了。所以修改类的操作我尽量收敛到管理员身上,调度员只负责执行层面的事情。权限控制用了轻量级的拦截器加注解,前端根据角色动态渲染菜单,后端在接口层面做鉴权校验,两边都在管,避免有人绕过前端直接调接口。
1.3 功能清单的收敛与优先级
项目的功能范围我一开始没有铺得很开,而是按使用频率排了优先级。第一梯队是班次排班和车辆状态管理,这是每天都要操作的;第二梯队是司机管理、路线站点管理、乘车记录;第三梯队才是统计报表和系统配置。
最终落地的功能模块包括:登录与权限校验、车辆管理(增删改查、状态流转)、司机管理、路线管理(路线表头加站点明细)、班次管理(按日期创建、车辆司机冲突校验、状态更新)、乘车核验(扫码或手动确认)、数据看板(今日班次、出车率、乘车人次)、报表导出。这个清单看起来不算庞大,但已经把调度系统的闭环走通了,非常适合作为扩展起点。
2. 技术选型解析:为什么是SpringBoot和Vue
2.1 后端框架的取舍逻辑
选择SpringBoot加MyBatis这套组合,我主要看中的是两件事:开发效率和SQL可控性。SpringBoot解决了传统SSH整合时一堆XML配置的问题,内嵌Tomcat让项目启动就是一个Main方法的事;MyBatis则把复杂查询的主动权还给开发者。
有朋友会问,现在JPA也很流行,为什么不用它。我的个人经验是,调度管理这类业务里报表查询和条件筛选特别多,比如“统计某辆车在某个时间段内的发车次数并按路线分组”,这种动态SQL用MyBatis写起来最顺手。JPA在简单CRUD上有优势,但遇到多表关联和动态条件,要么写@Query的JPQL,要么返回DTO还得做投影,不如直接在XML里写SQL来得直观。而且这个项目的学习门槛也是要考虑的,SpringBoot加MyBatis是Java岗面试里最常见的组合,技术栈本身有普适性。
2.2 前端选Vue的理由与组件生态
前端部分用了Vue 2搭配Element UI,这是目前中小后台管理系统里非常成熟的一套方案。Vue的双向绑定让表单这类交互密集的场景写起来很舒服,Element UI自带表格、弹窗、日期选择器、表单校验,不用从零造轮子。
为什么不选更重的框架或者更炫的方案?因为这类管理系统的核心是功能密度和信息展示效率,不是动画效果。Element UI的table组件配合自定义列模板,已经能覆盖路线站点展示、班次列表、车辆状态标签等90%的场景。另外,Vue的组件化特性很适合把“车辆状态卡片”“班次时间轴”这类业务组件抽出来复用,后期维护成本低。
要注意的是,我选Vue时特意确认了前后端完全分离的架构模式。前端通过Axios请求后端接口,后端只提供JSON数据,不返回页面。开发和部署时可以分开跑,前端用Node服务做开发代理,生产环境可以用Nginx托管静态文件并转发API请求。这种模式在团队协作时边界清晰,前端同学和后端同学可以并行开发。
2.3 前端请求封装与后端接口规范
为了不让代码混乱,我在前端统一封装了request工具,所有的Axios请求都走同一个实例。这个工具里做了三件事:统一拼接基础URL、自动携带登录后的token、统一处理后端返回的错误码并弹出提示。后端接口的返回值也做了统一包装,不管成功失败都返回一个固定的结构。
{ "code": 200, "message": "操作成功", "data": {} }这种约定有几个好处。前端拿到response后先判断code,不需要每个接口单独写异常分支;后端新增接口时,只要业务正常返回data,异常交给全局处理器接管,代码量会少很多。后面我会详细讲后端统一返回结构的具体实现。
3. 数据库设计:核心表结构与字段规划
3.1 实体关系的主线梳理
数据库设计是整个项目的地基,我在这块花的时间比写接口还要多。核心实体有六个:用户、车辆、司机、路线、站点、班次,另外还有一张乘车记录表作为班次和学生之间的关联。它们之间的关系其实不复杂:一条路线包含多个站点,一个班次绑定一条路线、一辆车、一个司机,一个班次会产生多条乘车记录。
在设计时我坚持了一个原则:不在业务表里堆冗余字段,但是允许在查询视图里做冗余。比如班次表里我存了route_id、vehicle_id、driver_id三个外键字段,而路线的名称、车牌号、司机姓名这些信息不直接存在班次表里,查询时通过关联去拿。这样做的好处是数据一致性有保障,修改车牌号后所有历史班次显示都会自动更新;代价是查询SQL要多写几个JOIN,但这正是MyBatis擅长的地方。
3.2 车辆表和司机表的设计要点
车辆表是最基础的主数据表,我设计的核心字段包括车牌号、座位数、车辆状态、购置日期、保险到期日、备注。车牌号必须做唯一约束,因为系统里所有地方都通过车牌号识别一辆车。车辆状态我用了三个值:0空闲、1出车、2维修。这个状态不是手动随便改的,而是通过班次的流转自动变化的,后面讲后端逻辑时会提到。
司机表比想象中要多考虑一点,除了姓名、手机号、驾驶证号、驾照类型这些基础字段,我还加了一个“可驾驶车型”的冗余标记。虽然实际业务里司机一般只开固定车辆,但系统设计时还是要预留这个维度,因为真实运营场景会出现临时替班。
3.3 路线站点表与班次表的设计
路线表本身比较薄,就是路线编号、路线名称、始发站、终点站、行驶距离和预计耗时。真正复杂的是站点表,因为站点有顺序。我用的方案是给路线站点表加一个station_order字段,类型是整数,从小到大表示停靠顺序,再加一个station_name表示站点名,arrive_minute_offset表示相对发车时间的到站偏移分钟数。
班次表是整个系统的核心业务表,字段包括班次日期、班次时间、关联路线、关联车辆、关联司机、状态、备注。这里设计了一个很有意思的细节:班次表和路线站点表本身没有直接关联,而是在查询时通过路线ID去拿站点列表。因为站点是路线的属性,班次只需要知道跑哪条路线,就自然知道了它要经过哪些站点。
3.4 索引设计与防冲突的唯一约束
数据库到了后期,性能瓶颈往往出在查询上。我在三张表上做了重点索引:班次表的班次日期和时间字段联合索引,因为排班页面最常用的查询就是按日期查;乘车记录表的班次ID索引,因为核验时要快速找到某个班次下的所有记录;车辆表和司机表的状态字段加普通索引,因为状态筛选很频繁。
这里必须强调一个约束设计:班次表上我加了唯一索引,索引字段是班次日期、路线ID、发车时间。为什么要加这个?因为同一路线的同一个发车时间本来就应该是唯一的,今天早上6:30发车的第2号线不可能有两趟。就算前端做了校验,后端代码也做了判断,数据库这一个唯一索引仍然是最后的保障,它可以彻底杜绝并发情况下排班重复的问题。这个索引在项目里的地位非常高,建议一定保留。
4. 后端接口设计与核心业务逻辑实现
4.1 分层结构与统一返回结果
后端项目我按五层来做:Controller层只做参数接收和结果转发,Service层写业务逻辑,Mapper层访问数据库,Entity层对应表结构,DTO层承载接口返回的数据结构。实际项目里很多人把Entity直接返回给前端,短平快,但隐患是表结构变更时接口也跟着变,所以我宁可多写几个DTO做转换。
统一返回结构这块,我定义了一个Result类,包里带了code、message、data三个属性,还有几个静态工厂方法如success()、error()。全局异常处理器用@RestControllerAdvice把业务异常和系统异常分别捕获,转换成Result对象返回。这样接口出现SQL异常时,前端不会看到一堆堆栈信息,而是收到一个准确的提示文案。
public class Result<T> { private Integer code; private String message; private T data; // 省略getter/setter public static <T> Result<T> success(T data) { Result<T> result = new Result<>(); result.setCode(200); result.setMessage("操作成功"); result.setData(data); return result; } }4.2 排班接口里的冲突校验逻辑
排班接口是核心中的核心,我在这个接口里写了一套完整的校验链路。首先校验参数里的班次日期、发车时间、路线ID不能为空;然后校验路线存在、车辆存在、司机存在;接着调车辆占用查询,看这辆车在目标时间段内是否已经分配了别的班次;最后调司机占用查询,看司机是否已经排了其他班次。
一辆车从早上6:30到7:10跑完一趟,理论上7:30是可以再跑下一趟的,所以冲突判断不能简单看日期相同就拒绝,而是要比较班次时间段的区间重叠。我在查询SQL里用了时间区间交叠的条件判断,基本逻辑是两个班次的时间范围有交集就视为冲突。比如新班次是7:20发车,之前有一个班次是6:30发车、预计7:10到达终点,它们没有交集,系统应该放行;如果之前班次预计7:40才结束,那7:20发车的新班次就会被拦截。
司机冲突的判断逻辑类似,但维度不同。司机不可能同时开两辆车,所以只要时间段重叠就拒绝;如果时间刚好能错开,比如前一班8:00结束,后一班8:20开始,那放行是合理的。
4.3 车辆状态流转的实现细节
车辆状态不能允许用户随意改,否则会出现“车明明在跑班次,状态却是空闲”的脏数据。我的方案是让状态跟着班次走:创建班次时如果车辆状态是空闲,则自动改成出车;班次完成后自动改回空闲;维修状态则作为手工维护的例外。
这个逻辑在Service层用事务控制,创建班次的方法上加了@Transactional,里面同时做两件事:插入班次记录、更新车辆状态。如果第二步失败,第一步会自动回滚,不会出现班次存在但车辆状态没更新的情况。班次完成状态同样在事务里处理,修改班次状态为已完成的同时更新车辆状态为空闲。
这里有一个容易踩的坑:如果车辆在维修期间被排班了怎么办?所以我额外加了一道校验,创建班次时如果车辆状态为维修,直接拒绝排班。这个规则在做需求分析时容易漏掉,实际运营里却非常重要。
4.4 乘车记录与核验流程
学生乘车核验我设计成两种方式。一种是在后台手动勾选班次下的站点和人员,一种是通过前端生成的乘车二维码配合扫码确认。考虑到真实场景里学生上车速度快,主要用的是方式一:调度员或随车老师先在系统里按站点提前录好“预计乘车学生名单”,学生上车时逐一点名确认。
对应的后端逻辑是乘车记录表。这个表有一个board_time字段,默认空,确认上车时写入当前时间,同时更新状态为1。班次完成后,系统可以统计该班次实际乘车人数,和预计人数做对比,这个对比数据对运营分析很有价值。
4.5 统计报表的联动SQL
报表模块我实现了三个常用指标:车辆出车率、线路日均运载人次、司机月度出班次数。实现路径并不复杂,核心就是分组统计SQL。
车辆出车率按月统计时,先查出这个月计划班次数,再查实际完成班次数,两者相除就是出车率。线路日均运载人次则是把乘车记录按路线分组,再除以日期数。这些SQL都写在MyBatis的XML里,用到CASE WHEN做条件统计,比在Java里做内存计算要高效很多。
5. 前端页面的模块划分与交互实现
5.1 页面路由与菜单结构
前端路由我用Vue Router做成了嵌套结构,布局组件负责左侧菜单和顶部栏,子路由是各个功能页面。菜单项包括首页看板、车辆管理、司机管理、路线管理、班次管理、乘车记录、报表统计、系统管理。路由守卫里做了登录态判断,没登录一律重定向到登录页;有角色标识的再加一道权限判断,查询角色看不到排班修改按钮。
页面风格上我统一了主色调,表格操作列固定右侧,筛选区放在内容区顶部,分页信息展示在表格下方。这些看起来是细枝末节,但实际使用体验差很多。尤其是班次管理页,我把日期筛选器放在最显眼的位置,默认显示今天,调度员打开页面不需要任何额外操作就能看到当前要处理的班次。
5.2 路线站点管理与顺序调整
路线管理页我分了两个区域:左边是路线列表,右边是当前选中路线的站点时间轴。路线列表用表格展示,站点时间轴用垂直步骤条展示,每个站点显示名称、顺序和到站偏移时间。新增站点时,默认排在最后一位,也可以填写序号后插入到中间位置。
这里有个交互细节:站点顺序改变后,到站偏移时间也需要重新计算。我的处理是前端把整个站点数组提交给后端,后端重新按station_order排序后统一更新。为了避免频繁请求,页面上提供了一个“保存站点顺序”按钮,用户拖拽或调整完后再批量提交,体验比较顺手。
5.3 班次排班页面的日历联动
班次排班页是使用频率最高的页面,我把筛选和排班操作合在了一起。顶部是日期选择,默认今天;中间是班次列表,每一行展示路线名称、发车时间、车辆车牌、司机、状态标签。创建新班次时弹出一个表单,里面路线、车辆、司机均用下拉选择器,选项数据来自各管理模块。
前端在下拉选择框选择车辆和司机后,会向后端发送一个预检查请求,接口返回车辆在该时间段是否空闲。这个预检查不是必须的,但是可以很大程度缩短用户等待时间,让冲突提醒在提交前就出现。提交表单时后端还会再做一次最终校验,保证并发安全。
5.4 移动端适配思路
考虑到部分使用场景是在校车上,我做了轻量级的移动端适配,但并没有单独开发App。方案是使用Vue项目加移动端响应式布局,在手机浏览器上打开同一个前端地址,菜单会自动折叠成顶部下拉,表格横向滚动。实际测试下来,随车老师在手机端查看班次信息、确认乘车记录,基本是能用的。
如果后续要做得更专业,也可以把查询和核验流程抽到一个uni-app的小程序里,复用现有的后端接口,技术上基本是现成的。因为接口已经按标准JSON返回,前端怎么换壳都不影响后端。
6. 源码部署与本地运行完整指南
6.1 环境准备和版本选择
本地跑通这个项目,我建议先装好以下环境:JDK 1.8或更高版本,Maven 3.6以上,MySQL 5.7或8.0,Node.js 14以上。没必要追求最新版本,这几个版本的稳定性和生态支持都够用。SpringBoot版本我用的2.x系列,对应的MyBatis Starter用2.1.x,Vue CLI用4.x,Element UI用2.15.x,这些版本组合经过大量项目验证,踩坑最少。
如果你在安装MySQL 8.0时遇到密码认证插件的问题,建议用caching_sha2_password的驱动版本,或者在连接串里指定allowPublicKeyRetrieval=true,这个属于高频问题,后面接排查章节细讲。
6.2 数据库初始化
项目里附带SQL脚本,里面包含建库、建表、初始化数据三部分。我的建议是先执行整个脚本文件,确认表都建成功后,再手动检查一遍核心表的数据。初始化数据里预置了一个管理员账号和一个调度员账号,密码都是经过MD5加密存储的。
CREATE DATABASE IF NOT EXISTS school_bus DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci; USE school_bus;重点提醒:字符集一定要用utf8mb4,不要用utf8。原因很简单,utf8mb4才能完整支持生僻字和emoji表情,而且和MySQL 8.0的默认行为一致。如果建库时用了utf8,后面录入学生姓名等数据时,遇到特殊字符就可能报错。
6.3 后端配置与启动
后端配置集中在resources/application.yml里,主要内容有数据源配置、端口配置、日志配置和JWT密钥配置。数据源里要改的是数据库URL、用户名、密码。端口我默认用的8080,如果你本地8080被占用,改成其他端口即可,前端代理也要对应调整。
server: port: 8080 spring: datasource: url: jdbc:mysql://localhost:3306/school_bus?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai username: root password: 123456 driver-class-name: com.mysql.cj.jdbc.Driver启动方式很简单,在项目根目录执行mvn spring-boot:run,或者在IDEA里直接运行启动类。控制台出现Started SchoolBusApplication后,后端就起来了。第一次启动可以先用Postman调一个登录接口试试,确认数据库连接没问题。
6.4 前端安装与代理配置
前端项目在vue-src目录下,进入目录后先执行npm install装依赖。如果网络不好,可以切换npm镜像源,这个属于常规操作。依赖装完后,开发模式直接npm run dev,默认端口是9528。
开发模式下前端通过Vue CLI的proxy代理把/api开头的请求转发到后端8080端口,这样就不需要处理跨域了。配置代码在vue.config.js里:
devServer: { port: 9528, proxy: { '/api': { target: 'http://localhost:8080', changeOrigin: true, pathRewrite: { '^/api': '' } } } }有个细节要说明:这里前端请求地址写的是/api/xxx,通过代理转发到后端时会把/api前缀去掉,后端接口实际路径是/login、/shift/list这些。这样做的好处是前端代码里统一带前缀,未来如果换成Nginx反向代理,不需要改动前端代码。
6.5 联调验证清单
系统部署完成后,我建议按以下顺序做一轮完整验证:用管理员账号登录,确认能进入首页看板;打开车辆管理,新增一辆测试车辆;打开路线管理,创建一条新路线并添加三个站点;打开班次管理,创建一条班次并关联上面新建的车辆和司机;在乘车记录里选择该班次,勾选一名测试学生,确认上车状态变为已上车;最后查看报表页面,确认统计数据有变化。
如果这七步都能顺畅走通,说明前后端、数据库、权限链路都是正常的。实际我自己在联调时经常卡在第4步的冲突校验上,原因是测试车辆被我之前建的班次占用了,此时回到车辆管理查看状态,果然已经变成出车。这个现象本身说明状态流转逻辑是对的,不是bug。
7. 常见问题排查与避坑实录
7.1 MySQL连接时区导致的报错
很多新手第一次启动后端,会看到类似The server time zone value...的报错。根本原因是MySQL 8.0之后时区设置和驱动的默认值对不上。解决办法是在JDBC连接串上显式指定serverTimezone=Asia/Shanghai。如果还不行,就在MySQL命令行里执行set global time_zone = '+08:00'。两种方式选一个即可,我更推荐第一种,因为它跟着项目走,换了环境也不会丢。
7.2 MyBatis XML里大于号和小于号的处理
动态查询里常常要写start_time >= #{startTime}这类条件。在XML文件里,大于号和小于号是特殊字符,直接写会导致解析失败。标准做法是使用转义符号,或者把整段SQL包在CDATA里。我习惯用转义,因为更直观,且前端高亮也比较友好。
<if test="query.startTime != null"> AND shift_time_start >= #{query.startTime} </if>这里还有一个隐蔽问题:XML的if标签里如果用大于号比较数字,比如 ,这个其实是允许的,因为test属性里不是SQL语句,用的是OGNL表达式,不受XML特殊字符限制。只有SQL语句部分需要转义。这个边界我最初也搞混过。
7.3 跨域问题的两种解法
开发模式下我推荐代理转发,生产环境下我推荐Nginx配置反向代理。如果非要开启后端CORS,可以用Spring的@CrossOrigin注解或者配置WebMvcConfigurer,但要注意不要允许全部域名,否则会有安全风险。
跨域的表现在浏览器里很明显:请求能发出去,响应也能拿到,但控制台报CORS错误,前端拿不到数据。排查时先用Postman直接请求后端接口,如果Postman能通、浏览器不行,那几乎可以断定是跨域问题,按代理方案调整即可。
7.4 Vue Router history模式刷新404
前端如果用了history路由模式,直接访问某个子路径时会404,因为服务器上并不存在这个物理路径。解决方法是后端做try_files回退到index.html,或者在开发模式用hash路由。我的建议是:本地开发和内部管理系统,直接用hash模式最省心,不需要额外配置服务器;如果确实要用history模式,Nginx配置如下。
location / { try_files $uri $uri/ /index.html; }7.5 排班并发导致的数据冲突
接口层校验完成后,理论上可以拦住绝大多数重复排班,但如果是两个人同时操作,或者前端重复提交,仍然可能在数据库层面产生冲突。唯一的兜底办法就是数据库唯一索引。我在班次表上建的唯一索引字段是班次日期、路线ID、发车时间,一旦数据库插入重复数据,会抛出DuplicateKeyException,全局异常处理器会把它转换成友好的提示信息返回给前端,比如“该班次已存在,请勿重复创建”。
这个经验我在其他系统里验证过很多次:代码逻辑做得再好,数据库约束都不能省,两者是互补关系,不是二选一。
7.6 车辆状态不一致的排查方法
如果出现车辆状态和班次状态对不上,多半是事务边界没有控制好。我遇到过的情况是:代码更新了班次状态但忘记更新车辆状态,或者在更新时抛了异常但前面的事务没有回滚。排查时可以写一个SQL把有问题的数据查出来:关联班次表和车辆表,看已完成班次的车辆是否还停留在出车状态。
SELECT b.id AS shift_id, b.vehicle_id, v.plate_no, v.status AS vehicle_status, b.status AS shift_status FROM shift b LEFT JOIN vehicle v ON b.vehicle_id = v.id WHERE b.status = 2 AND v.status = 1;如果查出数据,就需要补一个数据修复脚本,把对应车辆状态改回空闲。更重要的是从代码层面加事务注解,确保两个更新操作要么都成功,要么都失败。这个坑看起来小,但影响的是第二天的排班,不修的话车会一直显示占用,调度员会非常崩溃。
8. 写在最后的个人经验
整套系统做下来,我有一个很深的体会:校车调度管理系统真正难的地方,不在技术选型,而在业务规则的梳理。车的状态怎么流转、司机排班怎么避免冲突、路线站点怎么维护顺序、乘车记录怎么保证可追溯,这些才是决定系统好不好用的关键。技术本身是成熟的,SpringBoot配合Vue做这类后台管理系统,效率确实很高。
如果大家要在这个基础上继续扩展,我建议优先做两个方向。一是加一个通知模块,班次有变动时自动给家长或随车老师发短信或站内信,这个在真实运营里非常实用。二是接一个地图服务,把车辆实时位置展示在地图上,让家长能看到校车走到哪了,这个需求几乎是校车运营的标配。两个方向都在现有结构上增加模块即可,不需要动核心设计。
最后分享一个开发期的小技巧:先把车辆、路线、班次之间的核心约束想清楚,用数据库唯一索引和事务把这层关系固守住,再去铺页面和接口。顺序反了的话,后面越写越乱,改来改去总会有照顾不到的分支。这套项目我在本地反复跑了很多遍,按上文部署流程走一遍,基本都能顺利看到初始数据。如果中途卡住,优先检查数据库字符集和时区配置,十有八九问题出在这里。