Java维修系统实战:Spring Boot+MyBatis-Plus构建高并发、低配置维修业务系统
简介本资源是一套面向Java初学者与中级开发者的学习型项目源码聚焦二手电子产品维修服务全流程管理适用于课程设计、毕业设计或中小维修企业信息化系统原型开发。压缩包共370个文件总计784KB涵盖142个Java源文件承载核心业务逻辑、158个Class字节码含Users、MaintainOrder、PayOrder等关键实体与服务类、57个XML配置文件用于数据库连接与Spring框架配置及6个YAML文件支持环境化部署辅以Git忽略、IDE配置与LICENSE等工程必需文件。已有256人学习下载体现其在教学实践与轻量级系统开发中的实用价值。读者可直接导入IDE运行调试完整掌握基于Spring生态的分层架构设计、维修工单流转、客户与配件管理模块实现以及XML/YAML双配置模式下的系统集成方法具备良好的可扩展性与二次开发基础。1. 为什么一个“二手电子产品维修系统”非得用 Java 写——不是为了炫技而是修手机、换屏幕、查配件、开单、算账、管库存全得扛住每天 200 工单的并发压测和三年不重启的稳定运行你可能在招聘网站上见过这类 JD“熟悉 Spring Boot有维修管理类系统开发经验者优先”也可能在本地数码城二楼看到过贴着“苹果/华为/小米专修”的小门脸墙上挂着几台拆到一半的 iPhone柜台玻璃下压着一叠手写维修单。这两者之间缺的不是技术而是一套真正能跑在修理工手指头上的 Java 系统它要能扫码识别主板型号自动匹配兼容配件要能在师傅一边拧螺丝一边用安卓平板点“已更换电池”后端立刻扣减库存、生成电子工单、触发微信通知客户还要支持老板月底导出“屏幕更换TOP5机型”“平均返修率超15%的批次”这类报表。这不是 ERP 的简化版也不是 OA 的变体——它是嵌在维修动作流里的业务操作系统。Java 在这里不是因为“企业级”三个字好听而是因为它的线程模型能稳扛多终端录入前台接单后台仓管师傅PadJDBC 生态让 MySQL/Oracle 切换零成本Spring Security 能给店长、技师、仓管配三套细粒度权限更重要的是全国 73% 的中小维修连锁用的还是 Windows 7/10 旧电脑而 Java SE 8 的 JRE 仍能原生兼容——你没法指望 Python 的 pyinstaller 打包包在一台连蓝牙都打不开的老联想启天M430上顺利解压运行。本文就带你从零搭起这个系统不讲 MVC 是什么只告诉你 Controller 层怎么防住师傅误点“重复提交”、MyBatis 的 resultMap 怎么映射“维修项-子配件-历史记录”三级嵌套、库存扣减为什么必须加SELECT ... FOR UPDATE而不是简单 update以及——最实在的——源码里哪 3 个文件改完就能直接部署到修车行隔壁那台尘封已久的 Dell OptiPlex 3020 上跑起来。2. 用 Spring Boot 2.7 MyBatis-Plus 快速搭建维修核心模块从数据库建模到工单状态机落地2.1 数据库设计避开“一张大表全搞定”的玄学陷阱按维修生命周期切分四张主表维修系统最典型的翻车现场就是把“客户信息、设备信息、故障描述、配件清单、维修步骤、付款状态”全塞进一张repair_order表里。结果半年后老板要查“近三个月更换过原装电池但未做防水检测的华为机型”SQL 直接卡死。我们按真实维修动线拆成四张表字段精简到可维护表名核心字段非全部设计要点customerid,name,phone,wechat_id,created_atphone加唯一索引避免同一客户多次录入wechat_id存微信 openid用于后续消息推送deviceid,sn,brand,model,imei,customer_id,status0待检,1在修,2已取sn和imei均设唯一索引支持扫码枪快速定位status用 tinyint 而非字符串减少索引体积repair_orderid,device_id,submit_time,expected_finish,actual_finish,status0待接单,1已派工,2维修中,3待质检,4已完成,5已取消status字段是状态机核心所有业务操作必须通过状态流转校验如只有 status1 才能派工repair_itemid,order_id,item_type1人工费,2配件费,3检测费,item_name,price,quantity,part_code配件编码一张订单可含多条维修项支持“换屏贴膜清灰”组合计价part_code关联配件库为库存扣减埋点提示repair_order.status不要依赖前端传值所有状态变更必须走 Service 层的updateStatus(Long orderId, Integer fromStatus, Integer toStatus)方法内部先SELECT ... WHERE id ? AND status ?校验当前状态再UPDATE ... SET status ? WHERE id ? AND status ?——这是防止并发修改导致状态错乱的后悔药。2.2 Spring Boot 启动脚本与关键配置让系统在老式办公机上不爆内存很多维修店的电脑还是 4GB 内存 机械硬盘Spring Boot 默认启动参数会直接 OOM。我们在src/main/resources/application.yml中强制约束 JVMspring: datasource: url: jdbc:mysql://localhost:3306/repair_db?useSSLfalseserverTimezoneAsia/ShanghaiallowPublicKeyRetrievaltrue username: repair_user password: repair_pass_2024 driver-class-name: com.mysql.cj.jdbc.Driver jpa: hibernate: ddl-auto: none # 绝对禁止 auto-create生产环境必须手动建表 show-sql: false properties: hibernate: format_sql: false # 关键降低内存占用 server: port: 8081 tomcat: max-connections: 200 # 老机器扛不住 1000 连接 accept-count: 100 # 队列长度压缩到 100 # MyBatis-Plus 配置 mybatis-plus: configuration: map-underscore-to-camel-case: true log-impl: org.apache.ibatis.logging.stdout.StdOutImpl # 开发期看 SQL上线前注释掉 global-config: db-config: id-type: assign_id # 使用雪花算法生成 Long ID避免 UUID 占空间对应pom.xml中锁定轻量级依赖删掉所有spring-boot-starter-websocket、spring-boot-starter-cache等非必需模块dependencies dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId exclusions exclusion groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-tomcat/artifactId /exclusion /exclusions /dependency !-- 内嵌 Undertow 替代 Tomcat内存占用低 30% -- dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-undertow/artifactId /dependency dependency groupIdcom.baomidou/groupId artifactIdmybatis-plus-boot-starter/artifactId version3.5.3.1/version !-- 锁定 3.5.x兼容 JDK 8 -- /dependency dependency groupIdmysql/groupId artifactIdmysql-connector-java/artifactId scoperuntime/scope /dependency dependency groupIdorg.projectlombok/groupId artifactIdlombok/artifactId optionaltrue/optional /dependency /dependencies2.3 维修工单状态机实现用枚举 状态流转校验堵死“跳步”漏洞维修流程不是线性链条而是带条件分支的状态图比如“已派工”后可进入“维修中”但若客户临时加价则需退回“待接单”重新计价。我们用枚举定义合法流转// src/main/java/com/repair/enums/OrderStatusEnum.java public enum OrderStatusEnum { WAITING_ACCEPT(0, 待接单, Collections.singletonList(1)), // 只能流向 1已派工 ASSIGNED(1, 已派工, Arrays.asList(2, 0)), // 可流向 2维修中或 0退回 REPAIRING(2, 维修中, Arrays.asList(3, 5)), // 可流向 3待质检或 5取消 QUALITY_CHECK(3, 待质检, Arrays.asList(4, 2)), // 可流向 4完成或 2返工 COMPLETED(4, 已完成, Collections.emptyList()), CANCELLED(5, 已取消, Collections.emptyList()); private final int code; private final String desc; private final ListInteger allowedNext; // 允许流向的状态码列表 OrderStatusEnum(int code, String desc, ListInteger allowedNext) { this.code code; this.desc desc; this.allowedNext allowedNext; } public boolean canTransferTo(int targetCode) { return this.allowedNext.contains(targetCode); } // 根据 code 获取枚举实例 public static OrderStatusEnum fromCode(int code) { for (OrderStatusEnum e : values()) { if (e.code code) return e; } throw new IllegalArgumentException(Invalid order status code: code); } }Service 层状态更新方法强制校验// src/main/java/com/repair/service/impl/RepairOrderServiceImpl.java Service public class RepairOrderServiceImpl implements RepairOrderService { Override Transactional public boolean updateStatus(Long orderId, Integer targetStatus) { RepairOrder order orderMapper.selectById(orderId); if (order null) { throw new RuntimeException(工单不存在: orderId); } OrderStatusEnum current OrderStatusEnum.fromCode(order.getStatus()); if (!current.canTransferTo(targetStatus)) { throw new RuntimeException( String.format(状态非法流转从 %s(%d) 到 %s(%d), current.getDesc(), current.getCode(), OrderStatusEnum.fromCode(targetStatus).getDesc(), targetStatus) ); } // 加锁更新防止并发覆盖 int updated orderMapper.update( new UpdateWrapperRepairOrder() .eq(id, orderId) .eq(status, current.getCode()) // 确保当前状态未被其他线程修改 .set(status, targetStatus) .set(updated_at, LocalDateTime.now()) ); return updated 0; } }注意updateStatus方法中UpdateWrapper的.eq(status, current.getCode())是关键。它让 SQL 变成UPDATE repair_order SET status?, updated_at? WHERE id? AND status?如果此时另一线程已将状态改为 2本次更新将返回 0 行影响事务回滚——比在代码里if (order.getStatus() ! current.getCode())多一层数据库级防护。3. 配件库存精准扣减为什么不能用“update set stock stock - 1”而必须用 SELECT FOR UPDATE3.1 并发场景下的库存超卖血泪现场想象维修师傅 A 和 B 同时接到两台 iPhone 12 更换屏幕的单子。库存表里screen_iphone12的stock当前为 1。两人几乎同时点击“确认更换”后端执行UPDATE part_stock SET stock stock - 1 WHERE part_code screen_iphone12;结果两条 SQL 都成功执行stock变成 -1。第二天客户来取机发现屏幕没货只能道歉补发——这就是典型的“超卖”。根本原因在于UPDATE ... SET stock stock - 1是一个读-改-写操作但 MySQL 默认隔离级别REPEATABLE READ下它不锁住被修改的行直到语句结束才加锁中间存在时间窗口。3.2 正确解法SELECT ... FOR UPDATE 事务内原子扣减我们必须让“读库存”和“扣库存”成为不可分割的原子操作。MyBatis-Plus 本身不支持SELECT ... FOR UPDATE需手写 XML!-- src/main/resources/mapper/PartStockMapper.xml -- mapper namespacecom.repair.mapper.PartStockMapper select idselectForUpdateByCode resultTypecom.repair.entity.PartStock SELECT id, part_code, stock, updated_at FROM part_stock WHERE part_code #{partCode} FOR UPDATE /select update iddecreaseStock UPDATE part_stock SET stock stock - #{quantity}, updated_at NOW() WHERE part_code #{partCode} AND stock #{quantity} /update /mapper对应的 Service 方法Service public class PartStockService { Transactional public boolean decreaseStock(String partCode, Integer quantity) { // 1. 加锁查询当前库存 PartStock stock partStockMapper.selectForUpdateByCode(partCode); if (stock null) { throw new RuntimeException(配件不存在: partCode); } if (stock.getStock() quantity) { throw new RuntimeException( String.format(库存不足配件 %s 需求 %d当前仅剩 %d, partCode, quantity, stock.getStock()) ); } // 2. 执行扣减WHERE 条件确保不会扣成负数 int updated partStockMapper.decreaseStock(partCode, quantity); if (updated 0) { // 理论上不会走到这里因前面已校验 stock quantity throw new RuntimeException(库存扣减失败可能已被其他事务修改); } return true; } }提示SELECT ... FOR UPDATE在事务中执行会锁住part_code对应的行即使part_code有索引。这意味着当师傅 A 在扣screen_iphone12库存时师傅 B 的相同查询会被阻塞直到 A 的事务提交或回滚。这是牺牲一点响应时间换取数据绝对一致的必要代价。3.3 配件与维修项的强绑定用 JSON 字段存“更换记录”而非硬建关联表维修系统有个隐藏需求每次更换配件都要记录“谁换的、什么时候换的、旧件去向报废/返厂、新件序列号”。如果为每条更换记录建repair_part_log表字段爆炸且查询复杂。更务实的做法是——在repair_item表中增加log_jsonTEXT 字段存标准化 JSON[ { operator: 张师傅, time: 2024-06-15T14:22:33, old_sn: SN20240615001, new_sn: SN20240615002, action: 更换屏幕, dispose: 报废 } ]Java 实体类中用TableField(typeHandler JacksonTypeHandler.class)映射public class RepairItem extends ModelRepairItem { // ... 其他字段 TableField(typeHandler JacksonTypeHandler.class) private ListPartLog logJson; // 自定义 PartLog 类含 operator/time 等字段 }这样做的好处查询工单详情时repair_item表一次 JOIN 就能拿到所有更换记录不用N1查询日志表日志字段可为空不影响主流程未来扩展字段如拍照存证 URL只需改 JSON 结构不改表结构。4. 避坑维修系统上线前必须踩过的 4 个深坑每一条都来自真实翻车现场4.1 现象扫码枪扫出的 SN 码末尾多了个回车符\r\n导致SELECT * FROM device WHERE sn ?查不到设备原因Windows 系统下串口扫码枪默认以\r\n结束而 Java 的Scanner.nextLine()或BufferedReader.readLine()会吞掉\n但保留\r最终sn字段存了F123456789\r。解决在 Controller 接收参数后立即清洗PostMapping(/scan) public Result scanDevice(RequestParam String sn) { String cleanSn sn.trim().replaceAll([\\r\\n\\t], ); // 清除所有控制字符 Device device deviceService.getBySn(cleanSn); if (device null) { return Result.fail(未找到设备 cleanSn); } return Result.success(device); }4.2 现象师傅用安卓 Pad 提交工单部分中文显示为问号????原因MySQL 表和字段的字符集是latin1而非utf8mb4或 JDBC URL 缺少characterEncodingutf8mb4参数。解决登录 MySQL 执行ALTER DATABASE repair_db CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; ALTER TABLE device CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;检查application.yml中datasource.url是否包含characterEncodingutf8mb4必须显式声明url: jdbc:mysql://localhost:3306/repair_db?useSSLfalseserverTimezoneAsia/ShanghaiallowPublicKeyRetrievaltruecharacterEncodingutf8mb44.3 现象导出 Excel 报表时日期列显示为数字如 44820而非“2022-09-15”原因使用 Apache POI 导出时直接cell.setCellValue(LocalDateTime.now())POI 将其识别为 Excel 的日期序列号1900-01-01 起的天数但单元格格式未设为日期。解决创建日期样式并应用CellStyle dateStyle workbook.createCellStyle(); CreationHelper createHelper workbook.getCreationHelper(); dateStyle.setDataFormat(createHelper.createDataFormat().getFormat(yyyy-mm-dd)); cell.setCellStyle(dateStyle); cell.setCellValue(Date.from(localDateTime.atZone(ZoneId.systemDefault()).toInstant()));4.4 现象系统运行一周后MySQL 连接池报错 “Too many connections”服务假死原因MyBatis-Plus 的LambdaQueryWrapper在链式调用中未关闭QueryWrapper或 Service 方法未加Transactional导致连接未归还。解决强制所有数据库操作走Service方法禁止在 Controller 中直接调用 Mapper在application.yml中设置连接池硬约束spring: datasource: hikari: maximum-pool-size: 20 # 严格限制最大连接数 minimum-idle: 5 connection-timeout: 30000 idle-timeout: 600000 max-lifetime: 18000005. 让维修师傅愿意用用极简 UI 离线缓存 微信通知绕过“他们不想学新系统”的心理防线5.1 前端不做 SPA用 Thymeleaf Bootstrap 5 构建“三页流”界面维修师傅不是程序员他们需要的是打开浏览器 → 扫码 → 点一下“开始维修” → 完事。我们放弃 Vue/React用 Spring Boot 内置的 Thymeleaf 模板引擎只做三页/index.html大按钮“扫码接单”、“查看今日工单”、“配件库存”/scan全屏扫码区域扫码后自动跳转/order/{id}/order/{id}工单详情页顶部显示设备信息中部大按钮“更换屏幕”、“更换电池”、“清洁保养”底部“提交完成”HTML 片段/templates/order.html!DOCTYPE html html xmlns:thhttp://www.thymeleaf.org head meta charsetUTF-8 title th:text${order.device.brand} ${order.device.model} 维修/title link hrefhttps://cdn.jsdelivr.net/npm/bootstrap5.3.0/dist/css/bootstrap.min.css relstylesheet /head body div classcontainer mt-4 h2维修工单 #span th:text${order.id}/span/h2 div classcard mb-3 div classcard-body h5 classcard-title th:text${order.device.brand} ${order.device.model}/h5 p classcard-textSNspan th:text${order.device.sn}/span/p p classcard-text客户span th:text${order.customer.name}/span | span th:text${order.customer.phone}/span/p /div /div !-- 维修动作按钮组 -- div classbtn-group w-100 mb-3 rolegroup button typebutton classbtn btn-primary btn-lg flex-fill onclickdoAction(screen) 更换屏幕 /button button typebutton classbtn btn-success btn-lg flex-fill onclickdoAction(battery) 更换电池 /button button typebutton classbtn btn-warning btn-lg flex-fill onclickdoAction(clean) 清洁保养 /button /div div classalert alert-info strong提示/strong点击按钮即执行配件扣减、状态更新并发送微信通知。 /div /div script th:inlinejavascript function doAction(action) { fetch(/api/order/ [[${order.id}]] /action, { method: POST, headers: {Content-Type: application/json}, body: JSON.stringify({action: action}) }).then(r r.json()).then(data { if (data.success) { alert(操作成功已通知客户); location.reload(); // 刷新页面看最新状态 } else { alert(操作失败 data.msg); } }); } /script /body /html注意所有按钮点击后前端不跳转只fetch提交成功后location.reload()—— 这是最符合师傅直觉的操作点一下等个弹窗刷新就行。5.2 微信模板消息用企业微信免开发接入5 分钟开通通知能力维修店不需要自建公众号或小程序。我们直接对接企业微信免费、无需认证、支持个人手机号登录在 企业微信管理后台 创建「维修通知」应用获取AgentId和Secret在application.yml中配置wechat: corp-id: ww1234567890abcdef agent-id: 1000001 secret: aBcDeFgHiJkLmNoPqRsTuVwXyZ发送消息的 Service调用企业微信 APIService public class WechatNotifyService { Value(${wechat.corp-id}) private String corpId; Value(${wechat.agent-id}) private Integer agentId; Value(${wechat.secret}) private String secret; public void sendRepairComplete(String openId, String device, String orderNo) { // 1. 获取 access_token String tokenUrl https://qyapi.weixin.qq.com/cgi-bin/gettoken?corpid corpId corpsecret secret; String tokenJson restTemplate.getForObject(tokenUrl, String.class); String accessToken JsonPath.read(tokenJson, $.access_token); // 2. 发送模板消息 String msgUrl https://qyapi.weixin.qq.com/cgi-bin/message/send?access_token accessToken; MapString, Object payload new HashMap(); payload.put(touser, openId); payload.put(msgtype, template_card); payload.put(agentid, agentId); payload.put(template_card, Map.of( card_type, text_notice, source, Map.of(icon_url, https://example.com/icon.png, desc, XX数码维修), main_title, Map.of(title, 维修完成通知), emphasis_content, Map.of(title, 请取机, desc, 您的 device 已维修完毕), quote_area, Map.of(type, 1, url, https://example.com/order/ orderNo, appid, , pagepath, ), sub_title_text, 工单号 orderNo 取机时间即刻起, horizontal_content_list, Arrays.asList( Map.of(keyname, 维修项目, value, 屏幕更换贴膜), Map.of(keyname, 费用, value, ¥280.00), Map.of(keyname, 取机地址, value, XX数码城二楼208) ), jump_list, Arrays.asList( Map.of(type, 1, url, https://example.com/order/ orderNo, title, 查看工单详情) ) )); restTemplate.postForObject(msgUrl, payload, String.class); } }提示openId不是微信个人号 openid而是企业微信中客户联系功能分配的external_userid。维修员添加客户微信时用企业微信「客户联系」功能系统自动同步客户身份openId即可从回调事件中获取。这一步无需前端开发店长在后台点几下就配好。5.3 离线缓存策略让师傅在断网时仍能查历史工单维修店网络不稳定是常态。我们用 HTML5Cache Manifest虽已废弃但在内网环境稳定可靠实现离线访问创建cache.manifest文件放在src/main/resources/static/下CACHE MANIFEST # v20240615 CACHE: /index.html /css/bootstrap.min.css /js/bootstrap.bundle.min.js /favicon.ico NETWORK: /api/在index.htmlhtml标签中添加html manifest/cache.manifest启动时浏览器会下载CACHE列表中的资源。断网后访问/index.html仍能显示首页和按钮点击“查看今日工单”会提示“网络不可用请稍后重试”——比白屏友好十倍。6. 最后一个技巧用 Logback 的 SiftingAppender按维修门店分日志文件老板查问题不再需要翻三天日志维修连锁往往有多个门店A店、B店、C店共用一套后端系统。老板问“昨天A店的屏幕更换单为什么没生成”运维就得在application.log里 grep 三天——直到发现日志里混着 B 店的支付失败、C 店的库存告警。解决方案用 Logback 的SiftingAppender根据请求头中的X-Store-Id动态生成日志文件。第一步在src/main/resources/logback-spring.xml中配置appender nameSTORE_LOG classch.qos.logback.core.sift.SiftingAppender discriminator keystoreId/key defaultValueunknown/defaultValue /discriminator sift appender nameSTORE-${storeId} classch.qos.logback.core.rolling.RollingFileAppender filelogs/store_${storeId}.log/file rollingPolicy classch.qos.logback.core.rolling.TimeBasedRollingPolicy fileNamePatternlogs/store_${storeId}.%d{yyyy-MM-dd}.%i.log/fileNamePattern timeBasedFileNamingAndTriggeringPolicy classch.qos.logback.core.rolling.SizeAndTimeBasedFNATP maxFileSize10MB/maxFileSize /timeBasedFileNamingAndTriggeringPolicy maxHistory30/maxHistory /rollingPolicy encoder pattern%d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n/pattern /encoder /appender /sift /appender root levelINFO appender-ref refSTORE_LOG/ /root第二步写一个 Filter从请求头提取X-Store-Id并放入 MDCMapped Diagnostic ContextComponent public class StoreIdFilter implements Filter { Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { HttpServletRequest httpRequest (HttpServletRequest) request; String storeId httpRequest.getHeader(X-Store-Id); if (storeId null || storeId.trim().isEmpty()) { storeId unknown; } MDC.put(storeId, storeId); // 这个 key 必须和 discriminator.key 一致 try { chain.doFilter(request, response); } finally { MDC.clear(); // 清理避免线程复用污染 } } }第三步前端或 Nginx在每次请求头中带上门店标识curl -H X-Store-Id: A_SHOP http://localhost:8081/api/order/123效果日志自动分流到logs/store_A_SHOP.log、logs/store_B_SHOP.log……老板要查 A 店问题直接tail -f logs/store_A_SHOP.log5 秒定位。这个技巧背后是运维思维不要让业务方适应技术而要用技术适配业务场景。维修系统不是炫技的玩具它是师傅手边的螺丝刀、是老板抽屉里的记账本、是客户手机里的一条微信通知。我写这篇笔记时正看着自己修了三年的 MacBook 上贴着的那张泛黄维修单——上面手写着“更换 SSD¥320张师傅2021.08.12”。希望你搭起的系统也能在某个县城数码城的旧电脑上安静地跑下去不崩溃、不丢数据、不难用。希望帮到你。本文还有配套的精品资源点击获取