
简介这是一份基于Java开发的汽车维修管理系统设计源码适合Java学习者、毕业设计者以及中小型汽车维修企业技术团队参考用于快速搭建包含维修工单、客户车辆、零部件库存、财务结算等核心模块的管理原型。压缩包共40个文件主要包括29个Java源文件、8个XML配置、1个YAML配置、1个属性文件、1个JAR包及说明文档整体仅108KB结构紧凑覆盖业务逻辑、环境配置与构建说明。其中Java源文件承载系统主要业务实现XML与YAML用于数据库、权限和运行参数配置JAR包提供所需依赖pom.xml便于Maven构建管理。当前已有382人浏览学习。读者可借此理解汽车维修系统的模块划分、配置方式与工程组织方式也可在现有代码基础上二次开发适合作为课程设计、毕设选题或入门级企业管理系统学习的参考样例。1. 一套能直接跑起来的 Java 维修管理系统源码仓库里躺着一个只有 40 个文件的源码包readme 三行字第一眼确实让人提不起兴趣。但拆开看一遍就会发现真正值钱的不是代码量而是模块划分方式29 个 Java 源文件按维修工单、客户档案、车辆管理、零部件库存、财务结算五个业务域切开配合 8 个 XML、1 个 YAML、1 个 properties 和 Maven Wrapper把配置和构建环境都隔离好了。这套源码适合两类人一类是 Java 基础刚结课、想找一个完整业务闭环而不是增删改查 demo 的新手另一类是用 Spring Boot MyBatis 做管理系统、想抄一个装配完整骨架的从业者。它解决的问题非常具体维修工单状态怎么流转、领料怎么扣库存、结算金额怎么算下面按工程结构、业务模块、配置体系、构建排错四个层面逐个拆。2. Maven 工程结构与依赖治理2.1 从 pom.xml 看项目骨架拿到任何 Java 源码包第一件事永远是看 pom.xml而不是直接翻 .java 文件。这个项目的 pom 是典型的 Spring Boot 单模块工程packaging是 jar依赖以 spring-boot-starter-web 和 mybatis-spring-boot-starter 打底数据库驱动走 mysql-connector-java。结构示意如下project modelVersion4.0.0/modelVersion parent groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-parent/artifactId !-- 具体版本号以仓库 pom 锁定为准 -- relativePath/ /parent groupIdcom.repair/groupId artifactIdcar-repair-manage/artifactId version1.0.0/version packagingjar/packaging dependencies dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdorg.mybatis.spring.boot/groupId artifactIdmybatis-spring-boot-starter/artifactId /dependency dependency groupIdmysql/groupId artifactIdmysql-connector-java/artifactId scoperuntime/scope /dependency /dependencies build plugins plugin groupIdorg.springframework.boot/groupId artifactIdspring-boot-maven-plugin/artifactId /plugin /plugins /build /project逻辑说明spring-boot-starter-parent做依赖版本统一管理子模块不用写版本号避免依赖地狱mybatis-spring-boot-starter负责把 SqlSessionFactory 和 Mapper 扫描自动装配进来mysql 驱动声明成 runtime 是因为编译期用不到只有运行期加载 JDBC 驱动才需要。注意relativePath设为空表示父 POM 不从本地相对路径找而是直接走 Maven 仓库这能减少团队环境差异带来的构建失败。选型理由也不复杂单模块工程对应维修厂的系统规模拆成多模块反而增加沟通成本Spring Boot 内置 Tomcat打出来的 JAR 可以直接跑不需要额外装应用服务器这对小团队维护是实打实的省事。2.2 29 个 Java 源文件与业务域分组把 src/main/java 下的文件按包名过一遍29 个源文件的职责边界比预想中清楚。常见的分组方式如下表业务域对应文件核心职责维修工单RepairOrder 控制器/服务/Mapper/实体工单创建、接单、状态流转、完工确认客户与车辆Customer、Vehicle 系列客户档案、车辆档案、车牌与客户关联零部件库存Part、StockRecord、StockService配件信息、入库、领料出库、库存台账财务结算Settlement、OrderItem、LaborItem工时费与配件费计算、优惠折扣、结算单系统支撑User、Login、Result、GlobalExceptionHandler登录鉴权、统一返回结构、全局异常这五组对应关系基本就是维修企业的管理闭环客户带车进来开维修工单检修过程中领配件、算工时完工后结算最后更新车辆档案里的里程和保养记录。每组的文件数量不算多但 Mapper 接口和 XML 一一对应Service 层负责事务Controller 层只做参数接收和结果返回分层是标准的。值得注意的一点是Service 层里用了不少 Java 集合操作来做内存态的数据组装比如把订单明细按类型分组后汇总。这类写法在数据量小的内网管理系统里完全够用不必一上来就上缓存中间件。真要追过 MyBatis 源码就会知道Mapper 接口的实例本身是 JDK 动态代理生成的调用任何方法都会走代理逻辑所以在 Service 层过度包装 Mapper 反而会让问题排查变难。2.3 Maven Wrapper 与构建一致性仓库里的.mvn/wrapper目录、maven-wrapper.properties、MavenWrapperDownloader.java和唯一的 JAR 文件组成了 Maven Wrapper 机制。那个 JAR 是 maven-wrapper.jar属于构建工具而非业务依赖第三方库统一由 Maven 仓库按 pom 坐标拉取。Maven Wrapper 解决的是团队构建环境不一致的问题本地装了 Maven 3.9CI 机器是 3.6构建行为可能不同而 Wrapper 会把 Maven 版本固定到 properties 里指定的版本任何人执行./mvnw都等价于用同一版本构建。实际使用时就三个命令./mvnw clean package -DskipTests ./mvnw spring-boot:run ./mvnw dependency:tree -Dverbose参数说明-DskipTests跳过测试用例执行但保留测试代码编译比-Dmaven.test.skiptrue温和后者连编译都跳过容易放过编译错误。dependency:tree在排查依赖冲突时最常用后面第 5 章排错还会用到。首次执行./mvnw时MavenWrapperDownloader.java 负责把指定版本的 Maven 下载到本地~/.m2/wrapper所以公司内网开发需要提前准备镜像源否则首次构建会卡在下载阶段。3. 维修管理核心业务模块拆解3.1 维修工单状态机用枚举把流转规则收口维修工单是这套系统里牵一发动全身的核心实体。客户到店、车辆进车间、领料、结算、出厂全部围绕工单展开。工单状态如果放任 Service 层随意赋值两个月后就会出现「已完成还能被改回检修中」的怪事。这里的做法是把状态流转规则收口到枚举里public enum OrderStatus { PENDING(0, 待接单), DIAGNOSING(1, 检修中), WAIT_CONFIRM(2, 待客户确认), FINISHED(3, 已完成), CANCELED(9, 已取消); private final int code; private final String desc; OrderStatus(int code, String desc) { this.code code; this.desc desc; } /** 校验状态迁移是否合法不合法直接抛异常 */ public boolean canTransitTo(OrderStatus target) { switch (this) { case PENDING: return target DIAGNOSING || target CANCELED; case DIAGNOSING: return target WAIT_CONFIRM; case WAIT_CONFIRM: return target FINISHED || target DIAGNOSING; default: return false; } } }逻辑说明canTransitTo把每一跳的合法性集中定义Service 层调用时只要判断返回布尔值。WAIT_CONFIRM允许回退到DIAGNOSING对应现实业务——客户对报价有异议车间需要返工复检PENDING允许直接取消。这种设计在 Java 面试里也常被拿出来当状态模式的开场题核心不是背公式而是理解「状态迁移是业务规则不该散落在各处 if 里」。对应的 Service 方法长这样Transactional public void receiveOrder(Long orderId) { RepairOrder order repairOrderMapper.selectById(orderId); if (order null) { throw new BizException(工单不存在); } if (!order.getStatus().canTransitTo(OrderStatus.DIAGNOSING)) { throw new BizException(当前状态不允许接单); } order.setStatus(OrderStatus.DIAGNOSING); order.setAcceptTime(new Date()); repairOrderMapper.updateById(order); }参数说明Transactional保证「改状态 更新接受时间」要么都成功要么都回滚避免库表里出现状态变了但时间戳为空的脏数据。updateById是 MyBatis-Plus 风格的按主键更新如果项目用的是原生 MyBatis等价于写一条update只看主键条件的 SQL。这里隐藏的坑是并发两个操作员同时点接单理论上有都通过的窗口期量级小可以忽略想严谨就在 update 语句里加AND status 原状态做乐观锁影响行数为 0 就说明被别人抢先改了。3.2 客户与车辆档案的一对多建模客户和车辆在数据模型上是典型的一对多关系一个车主名下可以挂多台车而一台车只属于一个客户。建表前先画 ER 图会发现车辆表需要持有 customerId 作为外键而不是反着来。对应的实体类public class Vehicle { private Long id; // 主键 private Long customerId; // 客户外键逻辑关联 t_customer.id private String plateNo; // 车牌号 private String brand; // 品牌 private String model; // 车型 private Integer mileage; // 当前里程(km) private LocalDateTime createTime; }逻辑说明把车牌号设为单独字段而不是把整个车辆信息塞到客户表里是因为维修查询基本都是「按车牌找历史工单」。车牌号字段要加索引否则客户开单时按车牌检索会是全表扫描mileage用 Integer 存公里数别存成字符串后续按里程做保养提醒时 SQL 比较才能走索引。查询客户名下的车辆以及该车的历史维修记录在 Mapper XML 里写成一条连表 SQL 即可select idselectByPlateNo resultMapvehicleWithCustomerMap SELECT v.id, v.customer_id, v.plate_no, v.mileage, c.name AS customer_name, c.phone AS customer_phone FROM t_vehicle v LEFT JOIN t_customer c ON v.customer_id c.id WHERE v.plate_no #{plateNo} /select参数说明#{plateNo}走预编译占位符MyBatis 会生成 PreparedStatement从根源上防 SQL 注入LEFT JOIN保证即使客户资料被误删车辆的维修轨迹还能查出来。这里建议的做法是结果映射单独配resultMap把下划线命名转驼峰避免在 Java 里手工 get 一堆带下划线的字段。3.3 零部件领料与库存扣减的并发处理维修车间领料的场景是工单在检修中技师开出配件清单系统把对应零部件的库存减掉同时记一条出入库流水。这里最容易出的问题是并发扣库存——一张单子超卖两个工单同时领同一个件。先查再更新是典型的竞态写法正确做法是把校验放在 UPDATE 里UPDATE t_part_stock SET stock stock - #{quantity} WHERE part_id #{partId} AND stock #{quantity}逻辑说明这条 SQL 把「库存是否充足」的判断和扣减合并成一个原子操作。数据库行锁会保证同一时刻只有一个事务能更新这一行stock #{quantity}不满足时影响行数为 0Service 层拿到 0 就抛库存不足异常不需要显式加悲观锁。如果先 SELECT 再 UPDATE两个事务可能同时读到库存 5各扣 3最后库存变成 2 而不是 -1账就平不上了。库存扣减要和流水写入放到同一个事务里Transactional public void outbound(Long partId, Long orderId, Integer quantity) { int rows partStockMapper.deductStock(partId, quantity); if (rows 0) { throw new BizException(库存不足或配件已下架); } StockRecord record new StockRecord(); record.setPartId(partId); record.setOrderId(orderId); record.setQuantity(-quantity); record.setType(StockRecordType.OUTBOUND); stockRecordMapper.insert(record); }参数说明deductStock返回的是受影响行数用它判断成败比查库再判断更可靠流水号为负记出库、正记入库是台账对账的通用约定。回滚逻辑上只要insert抛异常前面的UPDATE也会回滚库存不会出现「扣了但没记录」的泄漏。3.4 财务结算BigDecimal 与 Stream 的配合结算单的金额由两部分组成工时费每项工时 × 单价加配件费每件配件 × 数量再乘以折扣系数。整套计算下来最值得注意的不是业务而是数据类型——金额字段一律用 BigDecimal用 double 算金额是财务模块的事故现场。计算逻辑public BigDecimal calcSettlement(RepairOrder order, ListPartUsage parts, ListLaborItem labors) { // 配件费单价 × 数量累加 BigDecimal partFee parts.stream() .map(p - p.getPrice().multiply(p.getQuantity())) .reduce(BigDecimal.ZERO, BigDecimal::add); // 工时费项数 × 工时单价累加 BigDecimal laborFee labors.stream() .map(l - l.getHourlyRate().multiply( BigDecimal.valueOf(l.getHours()))) .reduce(BigDecimal.ZERO, BigDecimal::add); BigDecimal total partFee.add(laborFee) .multiply(BigDecimal.ONE.subtract(order.getDiscountRatio())); return total.setScale(2, RoundingMode.HALF_UP); }逻辑说明用 Stream 的map把每条明细映射成金额再用reduce做归约求和比手写 for 循环 中间变量更像这个场景的标准解法。discountRatio是 0 到 1 的小数比如 0.1 表示九折结算单上要同时展示原价和折后价。setScale(2, RoundingMode.HALF_UP)强制保留两位小数并四舍五入这一步放在源头而不是展示层能保证入库的金额和打印的金额完全一致。这里顺带说一下 Java 集合的使用习惯明细计算完要按「工单号 项目类型」分组汇总时用Collectors.groupingBy分组比嵌套 Map 好读得多但如果分组后还要做多轮累加直接维护一个 HashMap 反而更直观。集合工具不是越花哨越好可读性优先。4. XML、YAML 与 Properties 三层配置体系4.1 MyBatis XML Mapper 里的动态 SQL这个仓库里的 8 个 XML拆分后看主要是两类一类是 MyBatis 的 Mapper 文件负责查询语句另一类是早期遗留的框架级 XML 配置。这套工程是 XML 与 YAML 并存的过渡形态先看 Mapper 文件里含金量最高的动态 SQL。维修工单的列表页通常要支持多条件组合查询按车牌、按状态、按时间范围条件可空可不空。如果每个组合都写一条 SQL排列组合下能写上百条。动态 SQL 用一个where加若干个if解决select idpageQueryRepairOrders resultTypecom.repair.entity.RepairOrder SELECT id, plate_no, customer_id, status, create_time FROM repair_order where if testplateNo ! null and plateNo ! AND plate_no LIKE CONCAT(%, #{plateNo}, %) /if if teststatus ! null AND status #{status} /if if teststartTime ! null AND create_time gt; #{startTime} /if /where ORDER BY create_time DESC /select逻辑说明where标签会自动处理掉第一个条件前面的 AND避免出现WHERE AND plate_no LIKE ...这种语法错误if里的test写的是 OGNL 表达式status ! null判断空值。注意时间比较里要写成gt;XML 的实体转义是新手最容易踩的坑。相比注解 SQL这类多条件动态查询放在 XML 里的优势在于改条件不用重新编译 Java 类DBA 可以直接拿文件评审 SQL而且LIKE CONCAT(%, #{plateNo}, %)这种写法是预编译的不用手动拼接百分号防注入。追过 MyBatis 源码的话会发现整个 Mapper 接口就是依靠动态代理加 XML 绑定实现调用的XML 的 namespace 必须和 Mapper 接口全限定名一致否则启动就报BindingException。4.2 YAML 配置与多环境切换application.yml 承担了应用层的主要配置比 XML 的尖括号结构清晰得多。核心片段server: port: 8080 spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/car_repair?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai username: root # password 不要写在库里用环境变量注入 profiles: active: dev mybatis: mapper-locations: classpath:mapper/*.xml type-aliases-package: com.repair.entity参数说明serverTimezoneAsia/Shanghai是 mysql-connector-java 8.x 的硬性要求漏了会报时区错误useUnicodetruecharacterEncodingutf8解决中文乱码必须放在 URL 上而不是靠连接池配置。mapper-locations指定 XML 文件的扫描路径type-aliases-package让 XML 里写的 resultType 可以直接用类名而不用写全限定名。多环境切换的做法是配spring.profiles.activedev再按环境拆 application-dev.yml、application-prod.yml切换时只改这一个值。注意密码这类敏感信息不要明文写在 YAML 里生产用${DB_PASSWORD}占位符从环境变量读取这是这套源码里最应该补的一处实践。4.3 Properties 文件密钥与日志级别properties 文件在 Java 工程里是最老牌的一种配置形式以键值对存储读起来直观改起来不用管缩进。在这个项目里它承担两类职责一类是 JDBC 连接参数的外置另一类是日志级别控制。示例# 日志级别控制 logging.level.rootinfo logging.level.com.repair.mapperdebug配置说明把 Mapper 包名下的日志级别调到 debug 后MyBatis 会在控制台打印每一条执行的 SQL 和参数列表排查「SQL 拼接结果跟预期不一致」这类问题特别有用。生产环境记得切回 info否则 SQL 全量打日志日志文件一天涨几个 GB 很正常。properties 文件与 YAML 并存时要注意优先级Spring Boot 默认的加载顺序里properties 配置的优先级通常高于同路径的 YAML。所以如果把数据库密码放在单独的 jdbc.properties再在代码里用PropertySource引入实际生效的取值可能会让人困惑。建议的口径是YAML 管应用行为properties 管日志和少量敏感项两者不要定义同一个 key。5. 构建、部署与典型踩坑处理5.1 从源码打包到 JAR 运行这套系统没有奇怪的部署依赖一条链路从源码到服务就三步# 第一步确认 Java 环境JAVA_HOME 指向 JDK8 export JAVA_HOME/path/to/jdk export PATH$JAVA_HOME/bin:$PATH # 第二步用 Wrapper 打包跳过测试 ./mvnw clean package -DskipTests # 第三步直接跑可执行 JAR java -jar target/car-repair-manage-1.0.0.jar --spring.profiles.activeprod参数说明clean先清掉 target 残留避免旧 class 混进新包--spring.profiles.activeprod是命令行方式覆盖 profile优先级高于 YAML 里的spring.profiles.active。跑不起来时九成问题出在 Java 环境变量配置上——mvnw脚本依赖 JAVA_HOME没有正确配置会直接报JAVA_HOME is not set或者java: command not found。先java -version确认版本再看echo $JAVA_HOME是否指向 JDK 根目录而不是 bin 目录。5.2 启动失败的常见原因对照把拆包和运行过程中高频出现的问题汇总成一张排查表报错现象根因处理方式Could not find or load main class打包产物不完整检查 spring-boot-maven-plugin 是否配置了 repackage 目标java.lang.NoClassDefFoundError依赖缺失或版本冲突跑./mvnw dependency:tree定位冲突用 exclude 排除Access denied for user rootlocalhost数据库账号密码错误核对 properties 里的连接参数确认授权后重启中文乱码连接 URL 缺字符集参数URL 补useUnicodetruecharacterEncodingutf8Port 8080 already in use端口被占用换server.port或查进程释放端口其中NoClassDefFoundError是 Java 面试里经常被拎出来问的「编译期好好的、运行期突然崩」的典型。处理套路也固定先看是哪个类缺失再顺着类名找是哪个依赖提供的然后mvn dependency:tree看是否被更高优先级版本覆盖。现象是编译和运行两套 classpath 不一致本质上是依赖治理没做好跟代码逻辑无关。5.3 内存与连接池参数的合理起步值维修厂这类内网管理系统并发峰值就是早晨开单那半小时几十个工位同时操作。JVM 内存和数据库连接池按这个量级给起步值是合理的java -Xms512m -Xmx1g -jar car-repair-manage-1.0.0.jarserver: tomcat: threads: max: 200 min-spare: 20 spring: datasource: hikari: maximum-pool-size: 16 minimum-idle: 4 connection-timeout: 30000参数说明-Xms512m -Xmx1g把堆初始值和最大值分开避免频繁扩容触发 Full GCTomcat 线程 200 对这个场景偏高但留了余量HikariCP 的maximum-pool-size是 16经验值是「核心线程数 × 2 磁盘数」不要看网上教程盲目调到 200连接池过大反而拖垮数据库。参数改完不是终点观察两个水位一是 GC 日志里的停顿时间二是连接池活跃连接数是否长期贴近上限这两个指标正常再谈扩容才靠谱。本文还有配套的精品资源点击获取