☰
Spring Boot项目实战:搭建汽车保养记录与提醒系统
2026/9/26 16:56:49 网站建设 项目流程

前阵子帮一位朋友整理用车资料,发现他的保养记录散落在三个地方:手机相册里存着结算单照片、副驾驶手套箱里塞着两张客户联、4S店系统里只有单一门店的记录。跨店保养之后想查历史,只能靠记忆和猜。这大概是很多车主的真实状态——不是没有保养意识,而是记录太散、提醒太被动。所以我干脆自己动手做了个轻量级的"车行无忧:汽车保养记录与提醒系统",把车辆档案、保养流水、周期规则、提醒通知串成一条线。这篇文章把整个项目的需求拆解、数据设计、核心逻辑和踩坑过程完整记录下来,适合有Spring Boot基础、想做一个完整实战项目的同学参考,也适合对"自己造一套工具解决真实生活问题"这件事感兴趣的朋友。

从"忘了保养"说起:这套系统解决的到底是什么问题

1.1 车主的三大真实痛点

第一个痛点是记录分散。保养单据在4S店、路边维修店、保险公司送的保养券、自己买的机油套装之间来回切换,每个服务方各留一摊记录,没有一份完整的车辆履历。卖车时想拿出"保养全程可追溯"的凭证,拼半天也拼不齐。

第二个痛点是周期模糊。很多人只记得"大概半年保养一次",但每辆车实际的周期取决于机油类型、行驶里程、用车强度。有的车开得少,半年才跑两千公里;有的车跑网约车,一个月就五千公里。单一的时间提醒根本不适用。

第三个痛点是提醒被动。4S店打电话来提醒,多数时候是营销话术,你分不清它到底是为你好还是想冲业绩;等仪表盘亮保养灯,往往已经超了。自己做Excel表也能记,但不会主动弹通知,手机换了文件还容易丢。

1.2 项目定位:它不只是个CRUD

我当时给这个项目的定位很明确:不是一个简单的增删改查教学项目,而是一个有业务规则、有定时任务、有多用户多车辆管理的完整小系统。它能同时管多辆车,每辆车独立记录保养流水,系统根据车型、里程、机油类型自动计算下次保养节点,到了节点主动通知车主。

整个系统叫"车行无忧",核心价值可以浓缩成一句话:把保养这件事从"靠记忆、靠电话催"变成"有记录、能计算、会提醒"。我在最初版本只实现三大模块——车辆档案管理、保养记录管理、周期提醒规则管理——每个模块都做扎实,不贪多。

技术选型:为什么锁定Spring Boot + MySQL组合

2.1 选型思考:什么方案最匹配这个场景

项目伊始我认真比较过几套方案。第一套是纯Excel + 手机备忘录,零成本,但无法多端共享,也无法自动化计算和推送;第二套是用Python写脚本定时扫描,记录体验太差,没有界面;第三套是前后端分离,Vue + Spring Boot,开发体验好,但单人维护成本高,光Node环境、跨域配置、打包部署就多出一堆事。

最终选了Spring Boot + MyBatis-Plus + MySQL的经典组合,理由很直接:Spring Boot天然适合这种中小型管理系统,MyBatis-Plus让单表操作免写大量SQL,MySQL存储结构化数据最稳。前端用JSP + Bootstrap,不用单独起前端工程,部署时一个WAR包搞定,对单人开发者非常友好。

还有一点私心,这个技术栈在求职简历上出镜率很高。SSM框架相关知识点几乎是大厂面试的必考题,做完这个项目再去看SSM相关的八股文,理解深度完全不一样,学习收益最大化。

2.2 项目目录结构与分层设计

工程采用经典的多层结构,包名按业务模块划分,不把Controller和Service堆在一起。我看到很多新手项目把所有代码塞进三个包——controller、service、mapper——结果一个controller几百行,完全没法维护。我的目录长这样:

com.chexingwuyou ├── controller # 控制层:车辆、记录、提醒、用户 │ ├── VehicleController.java │ ├── RecordController.java │ └── ReminderController.java ├── service # 业务层:周期计算、提醒逻辑 │ ├── VehicleService.java │ ├── RecordService.java │ └── RemindService.java ├── mapper # 数据访问层 ├── entity # 实体类 ├── config # 定时任务、Web配置 │ └── ScheduleConfig.java ├── common # 统一返回体、异常处理、工具类 └── CarWuyouApplication.java

分层的好处不必多说,关键是我把"周期计算"这类规则逻辑单独抽到RemindService里,没有塞进RecordService。因为记录新增之后要触发周期重算,定时任务扫描也要用同一套逻辑,如果写死在某个Controller里,第二处想复用就必须复制粘贴,后期维护就是噩梦。

2.3 关键依赖与项目初始化

用Spring Initializr创建项目时,我选了以下依赖,都是实际用得上的:

<dependencies> <!-- Web 支持 --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <!-- JSP 支持 --> <dependency> <groupId>org.apache.tomcat.embed</groupId> <artifactId>tomcat-embed-jasper</artifactId> <scope>provided</scope> </dependency> <dependency> <groupId>javax.servlet</groupId> <artifactId>jstl</artifactId> </dependency> <!-- MyBatis-Plus:简化单表 CRUD --> <dependency> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus-boot-starter</artifactId> <version>3.5.3</version> </dependency> <!-- MySQL 驱动 --> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <version>8.0.33</version> </dependency> <!-- 定时任务 --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter</artifactId> </dependency> <!-- 邮件通知 --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-mail</artifactId> </dependency> </dependencies>

提示:JSP放在src/main/webapp/WEB-INF/jsp/目录下,默认不能直接访问,必须要走Controller转发,这在Spring Boot 2.x里是个容易踩的坑。我一开始把JSP放在resources目录下,页面渲染一直404,折腾半天才发现路径不对。

数据模型设计:把"车辆保养"这件事拆成表

3.1 核心数据表结构与设计说明

数据模型是整个系统的地基。我设计了五张核心表,每张表的字段都经过斟酌,不是随手乱加的。先看整体结构:

用户表(t_user)

字段类型说明
idbigint主键,自增
usernamevarchar(50)登录名,唯一索引
password_hashvarchar(100)BCrypt加密后的密码
emailvarchar(100)接收提醒邮件的地址
phonevarchar(20)接收短信提醒的手机号(预留)
create_timedatetime创建时间

车辆表(t_vehicle)

字段类型说明
idbigint主键
user_idbigint所属用户,外键
plate_numbervarchar(20)车牌号,唯一索引
brandvarchar(30)品牌
modelvarchar(50)车型
buy_datedate购车日期
current_mileageint当前里程(公里)
last_maintain_mileageint上次保养时里程
last_maintain_datedate上次保养日期
create_timedatetime创建时间

保养记录表(t_maintain_record)

字段类型说明
idbigint主键
vehicle_idbigint车辆ID
maintain_datedate保养日期
mileageint保养时里程
item_namevarchar(100)保养项目(机油/机滤等)
costdecimal(10,2)费用
shop_namevarchar(100)服务商名称
next_datedate系统计算的提醒日期
next_mileageint系统计算的提醒里程
remarkvarchar(500)备注
create_timedatetime创建时间

提醒规则表(t_remind_rule)

字段类型说明
idbigint主键
vehicle_idbigint车辆ID
rule_typetinyint1=按时间,2=按里程,3=时间+里程双条件
interval_kmint里程间隔,如5000/10000
interval_monthint时间间隔,如6/12
is_activetinyint是否启用
update_timedatetime更新时间

通知记录表(t_notification)

字段类型说明
idbigint主键
user_idbigint接收用户
vehicle_idbigint关联车辆
contentvarchar(500)通知内容
read_statustinyint0=未读,1=已读
create_timedatetime通知时间

关于里程字段的类型,我特意用int而不是varchar。有人会把"当前里程"设计成字符串,理由是车牌号里有数字,但里程是参与数值比较的字段,字符串比较"9000 > 10000"会判断错,用int存才能做current_mileage - last_maintain_mileage >= interval_km这种计算。

3.2 为什么要有"提醒规则"这张独立表

最初版本我把周期写死在代码里,默认半年或五千公里。后来发现不同车辆差异很大:全合成机油可以一万公里一保,半合成五千公里就得换;有些车主一年开不到五千公里,按时间提醒更合理。把规则从代码里抽出来放进数据库表,用户可以自己调整每辆车的周期参数,系统不用改代码就能适配不同车型。

同时我把"最近一次保养的日期和里程"冗余存到了t_vehicle表里。有人会说冗余字段违反第三范式,但这是有意的取舍。提醒计算是高频查询,如果每次都要先到t_maintain_record表里SELECT MAX(maintain_date) GROUP BY vehicle_id,数据量大的时候效率很差。直接在车辆表上维持最新的保养快照,定时任务扫描时只查一张表,性能好很多。每次新增记录时在同一个事务里更新车辆表的冗余字段,一致性没问题。

3.3 MyBatis-Plus 的使用细节

我使用MyBatis-Plus的BaseMapper,单表操作基本不写XML。实体类上用@TableName注解映射表名,@TableId(type = IdType.AUTO)管理主键。需要注意一个坑:MyBatis-Plus默认的驼峰转下划线功能,如果实体字段叫nextMileage,它会自动映射next_mileage列,但前提是map-underscore-to-camel-case开启。我在application.yml里显式配置了这个参数,避免某些环境默认值不一致:

mybatis-plus: configuration: map-underscore-to-camel-case: true global-config: db-config: id-type: auto

核心业务实现:保养记录与提醒通知

4.1 新增保养记录的完整业务逻辑

新增保养记录是整个系统里事务最密集的接口,一个操作同时写多张表。我总结的流程如下:

  1. 前端提交保养日期、里程、项目、费用等信息;
  2. 后端先做基础校验:日期不能晚于今天、里程必须大于上次记录里程、费用必须大于等于0;
  3. 插入t_maintain_record;
  4. 根据车辆当前启用的提醒规则,计算下次保养日期和里程,回填到本次记录;
  5. 切到后台,用户看到的是"本次保养后,系统建议下一次保养日期为2025年8月12日,里程建议为65000公里"。

第4步是核心,提醒规则计算逻辑我抽到了RemindService.calculateNextRemind()方法:

@Override public RemindResult calculateNextRemind(Vehicle vehicle, RemindRule rule) { RemindResult result = new RemindResult(); // 按里程计算 Integer nextMileage = null; if (rule.getRuleType() == 2 || rule.getRuleType() == 3) { if (rule.getIntervalKm() != null && rule.getIntervalKm() > 0) { nextMileage = vehicle.getCurrentMileage() + rule.getIntervalKm(); } } // 按时间计算 LocalDate nextDate = null; if (rule.getRuleType() == 1 || rule.getRuleType() == 3) { if (rule.getIntervalMonth() != null && rule.getIntervalMonth() > 0) { if (vehicle.getLastMaintainDate() != null) { nextDate = vehicle.getLastMaintainDate().plusMonths(rule.getIntervalMonth()); } } } result.setNextMileage(nextMileage); result.setNextDate(nextDate); return result; }

双条件规则的核心逻辑是"先到为准"。代码里我既算出里程提醒值,也算出日期提醒值,后续定时任务扫描时,只要任意一个条件满足就触发通知。通过@Transactional注解保证整个流程要么全部成功要么全部回滚,不会出现记录写了但车辆冗余字段没更新这种脏状态。

4.2 保养周期规则引擎的三种实现思路

设计提醒扫描逻辑时我考虑过三种实现方式,这里逐一分析,方便大家做类似功能时直接参考。

第一种是每隔一段时间全表扫描,查所有启用了规则且未通知的车辆,比对当前时间和里程是否达到阈值。优点是实现简单,一个@Scheduled注解加一个查询就搞定;缺点是大数据量下会有一定压力。治理办法是给t_vehicle表的last_maintain_date、current_mileage建立联合索引,扫描时用分页控制内存。

第二种是懒计算,登录时计算用户的待提醒列表。优点是不耗后台任务,缺点是如果用户长期不登录,提醒就永远发不出去,失去了提醒的意义。

第三种是事件驱动,每次保养记录新增后立刻生成一条提醒定时任务,存放在单独的任务表。这是最合理但也是实现成本最高的方案,需要引入任务调度框架(如Quartz或XXL-JOB)。

我做的是第一种方案,选择理由很现实:家庭用户的车辆数据量撑死几百辆,每秒扫描一次也不会有性能问题。前两种方案在数据量增长后都可以平滑升级,而方案三一开始就引入了额外复杂度,对一个单机部署的小系统来说属于过度设计。

4.3 定时任务与通知推送

提醒扫描任务我放在ScheduleConfig.java里,用Spring自带的@Scheduled注解,每6小时执行一次。有人可能会问为什么不是每小时,因为保养提醒不需要分钟级实时性,如果用户当天添加了记录,最多延迟6小时也能收到通知,对用户感知影响很小,但能显著减少无效扫描。

@Component public class ScheduleConfig { @Autowired private RemindService remindService; @Scheduled(cron = "0 0 3,9,15,21 * * ?") public void scanRemindTask() { remindService.scanAndNotify(); } }

扫描逻辑如下:

public void scanAndNotify() { // 1. 查出所有启用了规则的车辆 List<Vehicle> vehicles = vehicleMapper.selectActiveVehicles(); LocalDate today = LocalDate.now(); for (Vehicle v : vehicles) { RemindRule rule = remindRuleMapper.selectByVehicleId(v.getId()); if (rule == null) continue; boolean needRemind = false; StringBuilder content = new StringBuilder("您的爱车【" + v.getPlateNumber() + "】该保养了:"); // 2. 按里程判断 if (rule.getRuleType() == 2 || rule.getRuleType() == 3) { int diff = v.getCurrentMileage() - v.getLastMaintainMileage(); if (diff >= rule.getIntervalKm()) { needRemind = true; content.append("已超出保养里程间隔"); } } // 3. 按日期判断 if (rule.getRuleType() == 1 || rule.getRuleType() == 3) { if (v.getLastMaintainDate() != null) { LocalDate leftDate = v.getLastMaintainDate().plusMonths(rule.getIntervalMonth()); if (today.isAfter(leftDate) || today.isEqual(leftDate)) { needRemind = true; content.append("已到保养时间节点"); } } } // 4. 去重判断:同一辆车同一个周期只通知一次 if (needRemind && !notificationMapper.existsUnread(v.getId(), today)) { saveAndSendNotification(v, content.toString()); } } }

判断"是否需要提醒"时,我用的是today.isAfter(nextDate) || today.isEqual(nextDate),逻辑上意思是超过或到达提醒节点就提醒。有一个细节容易被忽略:如果用户超期保养后没有及时新增记录,系统会在每个扫描周期都算出"需要提醒",然后重复发邮件骚扰用户。所以我在第4步加了去重判断,检查该车辆是否当天已经生成过未读通知,有则跳过。

提示:这个去重逻辑是按天去重的,我实测这样比较合理。用户看到邮件后去保养了,会新增记录并更新车辆表,扫描任务自然就不会再判定需要提醒。真实场景里,用户当天收到一条提醒可能高峰期来不及马上处理,第二天再收到一条也不会太烦。

通知通道我做的是"站内信 + 邮件"双通道。站内信在系统首页右上角有红点提示,邮件通过Spring Boot的JavaMailSender发送。使用邮件前需要在配置里加邮箱授权:

spring: mail: host: smtp.qq.com port: 465 username: your_email@qq.com password: your_auth_code protocol: smtps

实操避坑实录:从编码到部署的完整记录

5.1 时区与日期处理的那些坑

Java 8新增的LocalDate/LocalDateTime解决了旧版Date可变、时区混乱的问题,但引入了一类新问题:MySQL驱动配置serverTimezone不一致,会导致数据库存进去的时间和取出来相差8小时。

我在application.yml里统一设置:

spring: datasource: url: jdbc:mysql://localhost:3306/car_wuyou?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai

同时数据库连接池里的时区也要匹配,不然加了serverTimezone=Asia/Shanghai也是白搭。我踩过的坑是这样的:本机数据库存储的create_time是北京时间,但从t_maintain_record表查出来放到页面上显示,变成前一天晚上8点。最后排查发现连接驱动用的useJDBCCompliantTimezoneShift=true,JDBC默认使用服务器的时区,而数据库服务器时区是SYSTEM,和容器系统时区不一致。解决办法就是上面那个URL参数,以及在建库时明确指定字符集:

CREATE DATABASE car_wuyou DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;

日期格式化也值得统一。我在实体类的LocalDate字段上加了@JsonFormat(pattern = "yyyy-MM-dd")注解,避免前端收到带T的时间字符串,在JSP页面上用fmt:formatDate进行格式化,保证展示一致。

5.2 里程与数据的脏数据校验

系统的核心计算依赖两个数值:当前里程和上次保养里程。这两个数据如果出现脏值,整个提醒逻辑就废了。比如用户新增保养记录时,填的里程数比上次还小,就应该拦住。我的校验代码如下:

if (record.getMileage() < vehicle.getCurrentMileage()) { throw new BizException("保养里程不能小于当前车辆里程"); } if (record.getMileage() > vehicle.getCurrentMileage() + 50000) { throw new BizException("保养里程超出合理范围,请确认"); }

第二个校验可能有人觉得多余,但实际很实用。有些用户会误把表的某位数填错,多填一个零,如果不加这个"合理范围"校验,系统会把下次保养里程算到十万公里开外,导致提醒永远失效。

费用字段我用decimal(10,2)存储,但在前端提交时需要注意精度丢失问题。比如用户输入"530.5",MySQL默认保留两位小数,这里没问题。但如果你用float就不用保留两位了,会有0.01的误差。我全部用BigDecimal承接前端参数,计算也是BigDecimal,不直接用double。

5.3 定时任务与事务管理的相性问题

这是整个开发过程中最隐蔽的坑。定时任务方法scanAndNotify()里调用了saveAndSendNotification(),这个方法内部既有notificationMapper.insert(),又有sendEmail()。我最初在saveAndSendNotification()上加了@Transactional,结果发现邮件发送失败时通知记录也回滚了——邮件发了一次,但数据库里没记录;更惨的是重试时又发了一遍。

排查发现,@Transactional默认捕获到运行时异常就回滚,邮件服务器网络抖动导致MessagingException被包装成运行时异常,整个事务就回滚了。但邮件这个操作本身是不可回滚的外部副作用,已经发出去了。

解决办法是把邮件发送移到事务外面,或者降低事务粒度。我最终采用的做法是:先插入通知记录(走事务),再单独调用邮件服务发送(不在事务内),发送失败只记录日志不抛异常:

@Transactional public void saveNotification(Notification notification) { notificationMapper.insert(notification); } public void saveAndSendNotification(Vehicle v, String content) { // 1. 保存站内通知(事务内) saveNotification(notification); // 2. 发送邮件(事务外) try { mailService.sendSimpleMail(v.getUserEmail(), "车行无忧-保养提醒", content); } catch (Exception e) { log.error("邮件发送失败,vehicleId={}", v.getId(), e); } }

提示:定时任务方法本身所在的类里,如果方法加@Transactional,在Spring的代理模式下,同类内部的this调用不会走代理,注解可能失效。所以我把发送邮件逻辑放在另一个bean(MailService)里,用依赖注入的方式调用,而不是自己调自己。

5.4 部署打包的适当简化

系统最终打包成WAR放在Tomcat里运行。Spring Boot默认打包成可执行JAR,但JSP项目用JAR方式会有兼容问题。我改了pom.xml,把<packaging>war</packaging>设置好,同时把spring-boot-maven-plugin配置了mainClass。

我用Maven命令打包:

mvn clean package -DskipTests

产出的car-wuyou.war放到Tomcat的webapps目录下,启动后通过http://localhost:8080/car-wuyou/访问。因为用了spring-boot-starter-tomcat作为外部容器依赖,部署前要在启动类上继承SpringBootServletInitializer:

@SpringBootApplication public class CarWuyouApplication extends SpringBootServletInitializer { @Override protected SpringApplicationBuilder configure(SpringApplicationBuilder builder) { return builder.sources(CarWuyouApplication.class); } }

如果不想引进外部Tomcat,也可以继续用内嵌容器直接java -jar运行,但那样JSP页面路径要放在类路径下,改动会多一些。我选择WAR包部署,是因为服务器上本来就有现成的Tomcat,运维成本最低。

系统上线后的实际效果与进一步扩展

6.1 落地后的运行表现

系统上线三个月,我统计了一下实际使用数据。注册用户17个,绑定车辆23辆,共记录保养操作86次,触发提醒通知41条。最直观的变化是:我自己那台车原本八个月才想起来该保养,系统第二次提醒邮件发出后第三天就去了维修店。

有几个反馈很有意思。一位朋友说他在不同城市的店保养后,以前根本说不清上次换的是什么标号的机油,现在打开系统一查就知道上次用的是5W-30全合成,这次直接照着买,省了被店员推销高价油的钱。还有一位做二手车生意的朋友,把每台待售车辆的保养记录都录入系统,卖车时把记录打出来给买家看,成交率明显提升。

6.2 我复盘时认为值得做的四个升级方向

第一是接入微信通知。邮件提醒有个天然的缺陷——现在很多人根本不看邮件。微信通知可以通过Server酱、企业微信机器人或者自己跑一个微信公众号服务实现,比邮件触达率高得多。我计划后续优先做这个。

第二是保养项目自定义。目前只能按整车的"时间+里程"规则提醒,不够精细。实际场景里,空气滤芯两万公里换一次、刹车油两年换一次、火花塞六万公里换一次,每个保养项目都有独立的周期。下一步我想把提醒规则细化到"项目级别",在t_maintain_record里增加项目明细,t_remind_rule增加item_type字段关联具体项目。

第三是数据导出与分享。做一个导出功能,把某一辆车的完整保养履历生成PDF或Excel,方便用户在卖车、过户、保险理赔时使用。

第四是多端适配。现在页面是桌面优先的Bootstrap布局,手机浏览器上体验一般。准备引入响应式组件库或者直接做一个轻量级的移动端H5版本,毕竟车主在手机端打开系统的频率远高于电脑端。

6.3 给想复刻这个项目的人两条建议

第一,先用一个月时间把需求想清楚再动手。我第一版代码写得很快,但中途因为提醒规则设计反复改了两轮,前后返工超过两周。如果提前把"什么样的提醒是有意义的"想清楚——不是简单的时间加里程,而是考虑去重、多条件、用户操作闭环——后面会顺很多。

第二,不要被技术栈绑架。选Spring Boot还是SSM、用JSP还是Vue,这些都不是项目的核心。核心是你能不能把"车辆保养记录与提醒"这个业务逻辑写得严谨、好用。技术只是手段,用户愿意每天打开系统看到"离下次保养还差1820公里"才是真正的成功标准。

做个系统维护自己的车,体验真的很不一样。每次去保养完掏出手机记一条,看着系统自动算出下次节点,心里那种踏实感,是那些只会打电话推销的4S店永远给不了的。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询