前阵子帮一位朋友整理用车资料发现他的保养记录散落在三个地方手机相册里存着结算单照片、副驾驶手套箱里塞着两张客户联、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让单表操作免写大量SQLMySQL存储结构化数据最稳。前端用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 groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency !-- JSP 支持 -- dependency groupIdorg.apache.tomcat.embed/groupId artifactIdtomcat-embed-jasper/artifactId scopeprovided/scope /dependency dependency groupIdjavax.servlet/groupId artifactIdjstl/artifactId /dependency !-- MyBatis-Plus简化单表 CRUD -- dependency groupIdcom.baomidou/groupId artifactIdmybatis-plus-boot-starter/artifactId version3.5.3/version /dependency !-- MySQL 驱动 -- dependency groupIdmysql/groupId artifactIdmysql-connector-java/artifactId version8.0.33/version /dependency !-- 定时任务 -- dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter/artifactId /dependency !-- 邮件通知 -- dependency groupIdorg.springframework.boot/groupId artifactIdspring-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车辆IDmaintain_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车辆IDrule_typetinyint1按时间2按里程3时间里程双条件interval_kmint里程间隔如5000/10000interval_monthint时间间隔如6/12is_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 新增保养记录的完整业务逻辑新增保养记录是整个系统里事务最密集的接口一个操作同时写多张表。我总结的流程如下前端提交保养日期、里程、项目、费用等信息后端先做基础校验日期不能晚于今天、里程必须大于上次记录里程、费用必须大于等于0插入t_maintain_record根据车辆当前启用的提醒规则计算下次保养日期和里程回填到本次记录切到后台用户看到的是本次保养后系统建议下一次保养日期为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. 查出所有启用了规则的车辆 ListVehicle 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_emailqq.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?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai同时数据库连接池里的时区也要匹配不然加了serverTimezoneAsia/Shanghai也是白搭。我踩过的坑是这样的本机数据库存储的create_time是北京时间但从t_maintain_record表查出来放到页面上显示变成前一天晚上8点。最后排查发现连接驱动用的useJDBCCompliantTimezoneShifttrueJDBC默认使用服务器的时区而数据库服务器时区是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.5MySQL默认保留两位小数这里没问题。但如果你用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调用不会走代理注解可能失效。所以我把发送邮件逻辑放在另一个beanMailService里用依赖注入的方式调用而不是自己调自己。5.4 部署打包的适当简化系统最终打包成WAR放在Tomcat里运行。Spring Boot默认打包成可执行JAR但JSP项目用JAR方式会有兼容问题。我改了pom.xml把packagingwar/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作为外部容器依赖部署前要在启动类上继承SpringBootServletInitializerSpringBootApplication 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店永远给不了的。