
简介基于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变成跑一个 jarJPA 和 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 ResultLong create(RequestBody Valid VehicleCreateDTO dto) { Long id vehicleService.createVehicle(dto); return Result.success(id); } }Valid在 controller 层做参数校验ResultT是统一响应包装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 用一张表看清模块间依赖方向模块EntityRepositoryService核心操作车辆档案VehicleVehicleRepositoryVehicleService新增、停用、状态查询调度派车DispatchOrderDispatchRepositoryDispatchService派车、归还、改派驾驶员DriverDriverRepositoryDriverService资质校验、驾照到期提醒保险年检InsuranceRecordInsuranceRepositoryInsuranceService到期提醒、续保登记油耗台账FuelRecordFuelRecordRepositoryFuelService加油登记、百公里油耗统计依赖方向永远是 controller 依赖 serviceservice 依赖 repositoryrepository 依赖 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 读到的旧值大更新影响行数为 0OptimisticLockException被抛出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 JpaRepositoryVehicle, Long { ListVehicle findByStatus(VehicleStatus status); PageVehicle findByLicensePlateContaining(String keyword, Pageable pageable); ListVehicle findByStatusAndVehicleType(VehicleStatus status, VehicleType type); Query(select v from Vehicle v where v.status :status and v.insuranceExpireDate :date) ListVehicle 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 PageVehicle search(VehicleQueryDTO query) { SpecificationVehicle spec (root, cq, cb) - { ListPredicate 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 11这种粗糙写法。这种做法在车辆管理系统的查询场景里够用且可读不需要引入 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.urlserverTimezoneAsia/Shanghai强制指定MySQL 8 默认时区和 JVM 不一致会导致时间差 8 小时Hikari 连接池上限spring.datasource.hikari.maximum-pool-size1020车辆系统并发不高20 足够别盲目加大Jackson 时间格式spring.jackson.date-formatISO 格式yyyy-MM-dd HH:mm:ss前端展示和接口排查都要用统一时间串分页参数spring.data.web.pageable.max-page-size2000100防止有人拿 pageSize10000 把接口拖垮分页参数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:6379TZ: 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 nameFILE classch.qos.logback.core.rolling.RollingFileAppender file${LOG_PATH:-/logs}/vehicle-system.log/file rollingPolicy classch.qos.logback.core.rolling.TimeBasedRollingPolicy fileNamePattern${LOG_PATH:-/logs}/vehicle-system.%d{yyyy-MM-dd}.%i.log/fileNamePattern maxHistory30/maxHistory timeBasedFileNamingAndTriggeringPolicy classch.qos.logback.core.rolling.SizeAndTimeBasedFNATP maxFileSize100MB/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 前面挂一层 CDNCDN 回源到 NginxNginx 再反向代理到 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-age86400; proxy_set_header X-Real-IP $remote_addr; }^~表示一旦匹配该前缀就不再检查后面的正则 locationrewrite ... break去掉/files前缀后转发到 Spring Boot 的静态资源映射。Cache-Control: public, max-age86400让 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 的缓存规则否则负载均衡的探活结果永远是旧的流量切走都不自知。本文还有配套的精品资源点击获取