简介:基于Spring Boot框架的企业车辆管理系统,面向Java开发者与高校计算机专业学生,适用于课程设计、毕业设计或企业车辆信息化管理场景。系统涵盖管理员、驾驶员、用户三类角色,核心模块包括车辆登记、车辆运营、通用接口和配置管理。其中车辆登记支持分页查询、详情查看与增删改操作;车辆运营提供按值统计、时间统计和分组统计等数据分析能力;通用接口封装了字段查询、记录修改、状态更新、提醒计数、单列求和等实用功能,可帮助理解后端接口的通用化设计。
资源包为ZIP格式,共609个文件,大小约20.89MB。文件构成以Java后端源码和Vue前端组件为主,分别有139个Java文件和106个Vue文件,辅以161个SVG图标、41个JavaScript脚本以及XML、YML、Properties等配置资源,另含MySQL数据库脚本和Windows批处理运行脚本,便于本地搭建运行环境并快速启动项目。
已有72人学习浏览。对希望在真实项目中体会Spring Boot结合Vue进行前后端分离开发、了解企业级车辆管理业务与通用接口设计模式的读者,这份资源能提供完整可读的代码组织方式和可直接参考的部署流程,具备实用价值。
1. 为什么 Spring Boot 是企业车辆管理系统的合理底座
一个三百台车的企业,车辆信息散在 Excel 和微信群里,派车靠喊、钥匙靠找、公里数月底对不上,这是最典型的车辆管理痛点。把这个场景拆开看,车辆档案、出车申请、调度派车、归还确认、保险年检提醒、油耗台账,本质上是一套单据流加状态机,再加一层多角色权限的系统。Spring Boot 恰好是这类系统的主场:起步依赖把 Web、数据访问、事务、校验一次性配齐,内置容器让部署从"配 Tomcat"变成"跑一个 jar",JPA 和 MyBatis 两套生态都有成熟整合方案。
基于 Spring Boot 框架的企业车辆管理系统做源码交付并不稀奇,但源码的价值不在"能跑",而在你能从中抽出多少可以复用到下一个系统的套路:四层架构怎么分才不越界、状态流转的锁怎么加才不冲突、分页查询怎么写才扛得住上万条行车记录。这篇按一个一线工程师拿到这套源码后最常干的顺序来讲:先拆架构边界,再改业务代码,然后调部署参数,最后把图片和静态资源压到 CDN 上去。
2. Spring Boot 四层架构:车辆管理系统的模块边界与事务边界
2.1 四层架构在车辆管理系统里的实际分层方式
Spring Boot 四层架构在车辆管理系统里通常不是教科书那种严格的四层,而是 controller、service、repository、entity 四层加一个 dto 包。entity 只映射数据库表,不掺业务逻辑;repository 只做数据访问,接口方法名就是查询语义;service 层处理业务规则和事务边界;controller 层只负责参数校验和响应组装。这个系统的车辆模块、调度模块、驾驶员模块、保险模块各自按这个分包结构组织,包之间不允许跨层调用,比如 controller 不能直接注入 repository。
@RestController @RequestMapping("/api/vehicle") public class VehicleController { private final VehicleService vehicleService; public VehicleController(VehicleService vehicleService) { this.vehicleService = vehicleService; } @PostMapping public Result<Long> create(@RequestBody @Valid VehicleCreateDTO dto) { Long id = vehicleService.createVehicle(dto); return Result.success(id); } }@Valid在 controller 层做参数校验,Result<T>是统一响应包装,service 层完全感知不到 HTTP 的存在。这样做的直接好处是:将来把接口从 Web 换成消息队列触发,service 层一行不用改。分层不是炫技,是为了让事务边界落在 service 层这一处,而不是散在 controller 里。
2.2 为什么车辆调度的状态流转必须放在 service 层
车辆调度是这套系统里业务规则最密集的地方。车辆的可用状态从"空闲"到"已出车"再到"已归还",每一步都涉及多个表的联动:调度单要写状态、车辆要改可用标志、驾驶员要绑定行程、里程数要登记。如果在 controller 里逐个调用 repository,任何一个中间步骤失败,数据就会停在半路。把状态流转收敛到 service 层的一个方法里,用@Transactional包住整个流程,任何一步抛异常,前面写的记录全部回滚。
@Service public class DispatchService { private final DispatchRepository dispatchRepository; private final VehicleRepository vehicleRepository; private final DispatchLogRepository logRepository; @Transactional public Long assignVehicle(Long vehicleId, Long driverId, Long applicantId) { Vehicle vehicle = vehicleRepository.findById(vehicleId) .orElseThrow(() -> new BizException("车辆不存在")); if (vehicle.getStatus() != VehicleStatus.AVAILABLE) { throw new BizException("车辆当前不可调度"); } DispatchOrder order = new DispatchOrder(); order.setVehicleId(vehicleId); order.setDriverId(driverId); order.setApplicantId(applicantId); order.setStatus(DispatchStatus.DISPATCHED); DispatchOrder saved = dispatchRepository.save(order); vehicle.setStatus(VehicleStatus.DISPATCHED); vehicleRepository.save(vehicle); logRepository.save(DispatchLog.of(saved.getId(), "车辆已出车")); return saved.getId(); } }@Transactional注解是 Spring 声明式事务的核心,默认在 RuntimeException 上回滚,BizException继承 RuntimeException 所以也能触发回滚。注意这个方法的判断逻辑:车辆的status是数据库里的一个整数枚举,而不是用一个布尔字段"是否在用"。这样设计的原因下面会展开,但先记住,状态字段永远不要用「可不可以」这种二值表达,要用「当前处于哪个阶段」的多值枚举。
2.3 用一张表看清模块间依赖方向
| 模块 | Entity | Repository | Service | 核心操作 |
|---|---|---|---|---|
| 车辆档案 | Vehicle | VehicleRepository | VehicleService | 新增、停用、状态查询 |
| 调度派车 | DispatchOrder | DispatchRepository | DispatchService | 派车、归还、改派 |
| 驾驶员 | Driver | DriverRepository | DriverService | 资质校验、驾照到期提醒 |
| 保险年检 | InsuranceRecord | InsuranceRepository | InsuranceService | 到期提醒、续保登记 |
| 油耗台账 | FuelRecord | FuelRecordRepository | FuelService | 加油登记、百公里油耗统计 |
依赖方向永远是 controller 依赖 service,service 依赖 repository,repository 依赖 entity。Service 之间可以互相调用,但只能通过对方暴露的业务方法,不能直接摸对方的 repository。保险模块要查车辆信息,就调VehicleService.getVehicleSummary(),而不是自己注入VehicleRepository。这条约束在本地单人开发时看着多余,但当系统从一个人维护变成五个人维护,依赖不收敛的后果就是改一个字段名全局报错。
2.4 状态机与乐观锁:防止两辆车的调度冲突
车辆状态流转天然是一个状态机:可用、出车中、维修中、报废。四层架构把状态机规则放在 service 里,但真正防止冲突要靠数据库层的乐观锁。比如 AB 两个管理员同时看到同一台车"空闲",A 先点了派车,B 后点了派车,B 的更新必须失败。
@Entity @Table(name = "vehicle") public class Vehicle { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; private String licensePlate; @Enumerated(EnumType.STRING) private VehicleStatus status; @Version private Long version; }@Version是 JPA 的乐观锁标准做法,每次 update 时 version 加一,where 条件里带上旧 version。B 提交更新时,数据库中 version 已经比 B 读到的旧值大,更新影响行数为 0,OptimisticLockException被抛出,Spring 把这次业务操作判为失败。如果不用乐观锁,两个管理员同时派车,后一个会把前一个的状态覆盖回"出车",调度单和车辆状态就分叉了。要注意@Version字段不能被业务代码手动赋值,否则锁会失效。
3. 用 Spring Data JPA 实现车辆档案、查询与状态流转
3.1 JPA Repository 在车辆管理系统里的核心接口写法
Spring Data JPA 在这套系统里的定位是消灭手写 CRUD。车辆档案、调度单、油耗记录这三类表结构稳定、查询条件多变,用 Repository 接口方法名推导就能覆盖九成查询需求,剩下的一成再用@Query写 JPQL 或原生 SQL。接口方法命名的约定是 Spring Data JPA 最重要的知识点:findBy加字段名加条件关键字,框架在启动时解析方法名生成代理实现。
public interface VehicleRepository extends JpaRepository<Vehicle, Long> { List<Vehicle> findByStatus(VehicleStatus status); Page<Vehicle> findByLicensePlateContaining(String keyword, Pageable pageable); List<Vehicle> findByStatusAndVehicleType(VehicleStatus status, VehicleType type); @Query("select v from Vehicle v where v.status = :status and v.insuranceExpireDate < :date") List<Vehicle> findExpiringInsurance(@Param("status") VehicleStatus status, @Param("date") LocalDate date); }findByLicensePlateContaining会生成like '%keyword%'查询,适合车牌号模糊搜索。Pageable参数让系统天然支持分页。注意这里有个性能细节:findByStatus在车辆数据到了五千条以上时,这类的单纯枚举查询会走全表扫,常见做法是在status字段上建普通索引,二值且区分度低的字段加上组合索引才有意义。这套系统里"可用车辆列表"是最高频查询,直接给status + vehicle_type建联合索引。
3.2 车辆档案模块的实体设计与建表参数
车辆档案实体是这个系统的根实体,几乎所有模块都围绕它转。设计实体时不要图省事全用 String,枚举字段用数据库小整数存会有隐患:DBA 看库的时候不知道这个0、1、2是什么意思。JPA 的@Enumerated(EnumType.STRING)用字符串存枚举,可读性好,代价是占用几个字节的存储空间,几百台的车辆表根本不用在意这个开销。时间字段全部用LocalDateTime,别用java.util.Date,前者的时区行为在 Spring Boot 里默认处理得干净很多。
@Entity @Table(name = "vehicle", indexes = { @Index(name = "idx_vehicle_status", columnList = "status"), @Index(name = "idx_vehicle_plate", columnList = "license_plate") }) public class Vehicle extends BaseEntity { @Column(nullable = false, length = 10, unique = true) private String licensePlate; @Enumerated(EnumType.STRING) @Column(nullable = false) private VehicleStatus status; private LocalDate insuranceExpireDate; private LocalDate annualInspectDate; private Integer currentMileage; }BaseEntity里放了createdAt、updatedAt、deleted三个字段,其中deleted用@SQLDelete和@SQLRestriction做逻辑删除。车辆档案不适合物理删除,因为历史调度单外键指向车辆 ID,物理删掉后调度单查不到车牌号。同理,车牌号字段加unique = true是硬约束,这比在 service 层做"先查再插"的重复校验可靠得多。
3.3 多条件动态查询用 Specification 而不是硬拼 SQL
车辆列表页通常有四个筛选条件:车牌关键字、车辆类型、状态、保险到期时间范围。如果每加一个条件就写一个 Repository 方法,条件组合会爆炸。Spring Data JPA 的JpaSpecificationExecutor就是为这个场景准备的。让 Repository 同时继承JpaRepository和JpaSpecificationExecutor,然后用Specification拼接查询条件。
public Page<Vehicle> search(VehicleQueryDTO query) { Specification<Vehicle> spec = (root, cq, cb) -> { List<Predicate> predicates = new ArrayList<>(); if (StringUtils.hasText(query.getLicensePlate())) { predicates.add(cb.like(root.get("licensePlate"), "%" + query.getLicensePlate() + "%")); } if (query.getStatus() != null) { predicates.add(cb.equal(root.get("status"), query.getStatus())); } if (query.getInsuranceExpireBefore() != null) { predicates.add(cb.lessThan(root.get("insuranceExpireDate"), query.getInsuranceExpireBefore())); } return cb.and(predicates.toArray(new Predicate[0])); }; Pageable pageable = PageRequest.of(query.getPage(), query.getSize(), Sort.by("createdAt").descending()); return vehicleRepository.findAll(spec, pageable); }Specification的类型参数Vehicle告诉框架根实体是谁,root.get拿的是实体属性名而不是数据库字段名,所以实体字段改了名字,这里会编译期报错而不是查询期报错。动态条件全部走cb.and合并,不存在的条件不进 SQL,避免出现where 1=1这种粗糙写法。这种做法在车辆管理系统的查询场景里够用且可读,不需要引入 QueryDSL 那种重量级方案。
3.4 状态变更记录用一个独立的日志表
状态流转不能只看结果,还要能追溯过程。车辆状态从"出车中"变成"维修中",是谁在什么时间改的?光在 vehicle 表上把字段覆盖掉,审计信息就丢了。这套系统的常见做法是落一张vehicle_status_log表,service 层每次状态变更都追加一条记录。表结构很简单:vehicle_id、from_status、to_status、operator_id、remark、created_at。查询当前状态去 vehicle 表,查看完整历史去日志表,两者各司其职。
CREATE TABLE vehicle_status_log ( id BIGINT PRIMARY KEY AUTO_INCREMENT, vehicle_id BIGINT NOT NULL, from_status VARCHAR(20) NOT NULL, to_status VARCHAR(20) NOT NULL, operator_id BIGINT NOT NULL, remark VARCHAR(255), created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, KEY idx_vehicle_log (vehicle_id, created_at) ) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4;from_status和to_status用 varchar 存字符串枚举值,比 TINYINT 好在哪?导出给财务做用车统计时,别人不用查字典表就知道车从"AVAILABLE"变到了"DISPATCHED"。日志表只做插入和按车辆 ID 查询,不更新不删除,所以不需要设计修改字段。写入日志的操作放在@Transactional的 service 方法内部,和状态变更共用一个事务,保证状态改了日志一定同步落库。
4. application.yml 参数、上传与监控:部署前的最后一公里
4.1 四个必调参数:连接池、分页、时区和日志级别
拿到源码后先别急着点运行,打开application.yml把下面这组参数过一遍。这套系统最常见的本地和线上行为不一致,九成出在这几个参数上。
| 参数 | 配置位置 | 默认值 | 推荐值 | 说明 |
|---|---|---|---|---|
| 数据库时区 | spring.datasource.url | serverTimezone=Asia/Shanghai | 强制指定 | MySQL 8 默认时区和 JVM 不一致会导致时间差 8 小时 |
| Hikari 连接池上限 | spring.datasource.hikari.maximum-pool-size | 10 | 20 | 车辆系统并发不高,20 足够,别盲目加大 |
| Jackson 时间格式 | spring.jackson.date-format | ISO 格式 | yyyy-MM-dd HH:mm:ss | 前端展示和接口排查都要用统一时间串 |
| 分页参数 | spring.data.web.pageable.max-page-size | 2000 | 100 | 防止有人拿 pageSize=10000 把接口拖垮 |
分页参数max-page-size很多人不知道,Spring Data Web 默认允许前端传很大的 pageSize,车辆系统里一张记录表可能存了五年的加油数据,不加这个上限一个恶意请求就能让内存吃紧。serverTimezone必须在 JDBC URL 里显式声明,别依赖服务器系统时区,线上容器换了宿主机就可能出问题。
4.2 车辆图片上传:本地目录映射和访问路径
车辆照片、行驶证、保险单扫描件都要落到服务器上。常见做法是配置文件里指定上传根目录,上传接口把 MultipartFile 写到该目录下,然后通过一个本地目录映射把这些静态资源暴露出去。千万别把图片存进数据库的 BLOB 字段,备份表和查库都会变得很痛苦,文件就放文件系统,数据库只存相对路径。
app: upload: base-dir: /data/vehicle/upload url-prefix: /files/@Configuration public class WebConfig implements WebMvcConfigurer { @Value("${app.upload.base-dir}") private String uploadBaseDir; @Override public void addResourceHandlers(ResourceHandlerRegistry registry) { registry.addResourceHandler("/files/**") .addResourceLocations("file:" + uploadBaseDir + "/"); } }addResourceHandler的/files/**是访问路径,addResourceLocations必须用file:前缀开头指向磁盘真实目录,结尾的/不能丢。上传接口按日期分二级目录存放,文件名用 UUID 重命名,原始文件名单独存数据库字段展示用。面积小巧的 jpg 直接存原图就行,车辆系统没有高并发图床的需求,别加缩略图逻辑给自己找不必要的事。
4.3 Actuator 监控接口要暴露但必须加认证
Spring Boot Actuator 在这套系统里用来做健康检查和生产环境探活,/actuator/health可以放给负载均衡器做探活,但/actuator/env、/actuator/heapdump这类接口一旦裸奔,等于把数据库连接串和内存快照直接送人。热搜词里那个"spring boot actuator未授权访问"说的就是这个,默认 2.x 之后 Actuator 只暴露 health 和 info,但如果有人为了图方便把 exposure 改成了 include: '*' 又没加 security,风险就是这个。
management: endpoints: web: exposure: include: health,info,metrics endpoint: health: show-details: never server: port: 9090把 Actuator 的端口单独拆到 9090,和业务端口 8080 隔离,然后在这个端口上另外加一层 Spring Security 的 basic auth。show-details: never是防止健康检查把数据库连接异常细节暴露出去。生产环境更稳妥的做法是加上management.server.address只允许内网 IP 访问,或者直接用防火墙规则限制这个端口的外部入站流量。
4.4 用 docker compose 把 MySQL 和 Redis 一并拉起来
源码包里如果带了 Dockerfile 和 docker-compose.yml,本地开发环境一键起。即使没带,自己补一个也不费事。车辆管理系统的典型依赖是 MySQL 放业务数据、Redis 放验证码和分布式锁,两者都可以用 docker compose 管理。注意 MySQL 容器要挂宿主机磁盘目录,否则容器一删数据全没。
version: "3.8" services: mysql: image: mysql:8.0 container_name: vehicle-mysql restart: always environment: MYSQL_ROOT_PASSWORD: root123 MYSQL_DATABASE: vehicle_mgmt TZ: Asia/Shanghai ports: - "3306:3306" volumes: - ./data/mysql:/var/lib/mysql redis: image: redis:7-alpine container_name: vehicle-redis restart: always ports: - "6379:6379"TZ: Asia/Shanghai必须在容器环境变量里指定,否则 MySQL 容器的时间是 UTC,写入的created_at全是 8 小时之前的时间。restart: always保证宿主机重启后容器自动拉起。本地调试时 MySQL 和 Redis 都映射到宿主机端口,Spring Boot 在宿主机器上直接连 localhost 即可,不需要先构建应用镜像。
4.5 日志落盘并交给 Filebeat 收集
日志是排查问题的最小依仗,车辆系统的派车接口出了问题,第一步就是看这个订单 ID 的完整日志链路。约定俗成的做法是 logback 里配置滚动文件输出,文件按天切分,保留 30 天,然后用 Filebeat 把日志文件收集到 Elasticsearch。下面这段 logback-spring.xml 的关键配置值得直接抄。
<appender name="FILE" class="ch.qos.logback.core.rolling.RollingFileAppender"> <file>${LOG_PATH:-/logs}/vehicle-system.log</file> <rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy"> <fileNamePattern>${LOG_PATH:-/logs}/vehicle-system.%d{yyyy-MM-dd}.%i.log</fileNamePattern> <maxHistory>30</maxHistory> <timeBasedFileNamingAndTriggeringPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedFNATP"> <maxFileSize>100MB</maxFileSize> </timeBasedFileNamingAndTriggeringPolicy> </rollingPolicy> <encoder> <pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern> </encoder> </appender>%d是日志时间,%thread是线程名,%logger{36}是缩短后的类名,这个 pattern 是业界最常见的格式。Filebeat 侧配好 input 路径指向/logs/vehicle-system.*.log,加一个pipeline做时间字段解析,Kibana 里就能按天检索了。日志分级上有两条红线:业务异常用 error 级别并带上订单 ID 和参数,但不要把stack_trace全量堆到日志文件,磁盘写爆比代码 bug 更难排查。
5. CDN 缓存与车辆图片回源:把查询负载压下去
车辆系统的读写比例通常是 9 比 1,其中图片访问又占了大头。一个车队三百台车,每台车五张照片,总量才一千多张,但选车时驾驶员列表的缩略图会反复请求。把这层静态流量从应用服务器剥离出去,能明显降低 Tomcat 线程占用。常见做法是在 Nginx 前面挂一层 CDN,CDN 回源到 Nginx,Nginx 再反向代理到 Spring Boot 应用,图片资源请求从浏览器直达 CDN 边缘节点,命中缓存后应用服务器完全不感知。
Nginx 上给/files/路径配置一段强缓存和重写规则。
location ^~ /files/ { rewrite ^/files/(.*)$ /$1 break; proxy_pass http://127.0.0.1:8080; proxy_set_header Host $host; add_header Cache-Control "public, max-age=86400"; proxy_set_header X-Real-IP $remote_addr; }^~表示一旦匹配该前缀就不再检查后面的正则 location,rewrite ... break去掉/files前缀后转发到 Spring Boot 的静态资源映射。Cache-Control: public, max-age=86400让 CDN 边缘节点缓存一天,车辆图片是上传后基本不变的文件,这个时长完全安全。CDN 侧的回源配置里把回源 HOST 指到 Nginx 的对外域名,源站的X-Real-IP头要透传客户真实 IP,这样应用日志里看到的不是 CDN 节点地址。
做完缓存后在服务器上验证回源是否顺畅:
curl -I "https://cdn.example.com/files/2025/06/vehicle-123-photo.jpg"看响应头里如果出现x-cache: HIT,说明浏览器到 CDN 的链路命中了缓存;出现x-cache: MISS后紧跟一次刷新变 HIT,说明回源逻辑正常;连续多次 MISS 就要检查 CDN 的回源 HOST 和 Nginx 的转发规则了。另一项要验证的是Cache-Control有没有从响应头里正确返回,CDN 上没有重写缓存策略配置时应沿用源站这个头。这个配置里有个反向约束:/actuator/health这类动态接口绝不能配置进 CDN 的缓存规则,否则负载均衡的探活结果永远是旧的,流量切走都不自知。
本文还有配套的精品资源,点击获取