☰
Java二手电子产品维修系统:工单状态机与库存事务设计
2026/10/1 4:05:42 网站建设 项目流程

简介:基于Java语言的二手电子产品维修系统设计源码,是一套面向高校学生、Java开发者和维修服务商的完整项目资源,用于快速搭建涵盖维修任务、配件库存、客户信息与进度管理等核心模块的管理平台。压缩包共370个文件,约784KB,包括158个class字节码、142个java源文件、57个xml配置、6个yml配置、3个gitignore、2个iml、1个license及1个txt说明,覆盖后端业务逻辑、Spring体系配置、IDE工程结构与版本控制规范,体量紧凑但结构完整。目前已有255人学习/下载。源码采用模块化与面向对象设计,后端基于Spring框架,核心业务类覆盖用户、维修单、预约、支付、故障选项等典型模块,分层清晰,便于二次开发;借助该资源可快速理解二手电子维修场景下的数据模型与业务流转,适合作为毕业设计、课程项目或小型企业系统的参考实现,也可作为学习Java工程结构与Spring配置的实战案例。对于正在寻找JavaWeb完整项目的开发者,这份源码省去了从零搭建框架和设计表结构的时间,可直接在其上扩展功能。

1. 二手电子产品维修系统到底是什么:一张维修工单背后要管多少数据

先说结论:基于 Java 的二手电子产品维修系统设计源码,做出来不是让你真的去开一家维修铺,而是让你用一套能跑的 Web 业务系统,把“客户送修、技师检测、配件更换、结算取件”整个流程管起来。这个标题下的源码,本质上是典型的 Java Web 课程设计或毕业设计项目,核心是用 Spring Boot 这类框架实现工单管理、配件库存和客户档案三块业务。它解决的痛点是纸质登记单容易丢、配件账目对不上、维修进度靠吼。适合谁用?正在做 Java 课程设计的学生、想攒一个完整项目经验的初级开发者,以及小维修门店想低成本数字化的店主。所以下面从业务模型讲起,落到可复现的源码结构和代码,最后把坑一条条给你列清楚。

2. 业务模型先立住:维修工单、配件库存与客户档案的关系设计

2.1 维修系统的核心数据对象:工单、配件、客户与技师

做系统之前先分清四个核心实体:客户、工单、配件、技师。很多课程设计源码一上来就写用户表和登录,把业务主体丢了。二手电子产品维修系统的业务主体是工单,其他表都是围绕工单服务。

工单表最少包含这些字段:工单号、客户ID、设备类型(手机/笔记本/平板)、品牌型号、故障描述、接单状态、维修进度、收入金额、成本金额、创建时间、完成时间。配件要单独建表,因为一张工单可能用到多个配件,配件有自己的库存、进货价和销售价。技师也应独立建表,不要用一张通用员工表硬凑,维修系统里技师有擅长设备类型这个属性,比如“主板维修”“屏幕更换”,后续派单时按技能匹配是非常自然的扩展点。

客户和工单是 1 对 N 关系,一个客户可能送修多次,每次生成一张新工单。工单和配件是多对多关系,通过中间表“工单配件明细”关联,明细里记录数量、结算单价。客户档案的关键字段是姓名、电话、微信号(用于取件通知)、常用设备类型。电话必须有唯一索引,否则同一个客户第二次送修时又建一条记录,统计客户数就虚高,月底对账也对不上。

2.2 状态机驱动:维修进度从“待接单”到“已取件”的流转规则

状态机是这套系统里最能体现设计能力的地方。一张工单从录入到结单,至少要经历六个状态:待接单、检测中、待用户确认、维修中、待取件、已取件,外加一个“已关闭”用于用户放弃维修的场景。注意“待用户确认”不能省,因为商家报价后用户可能嫌贵不修,这是真实业务里的卡点。

状态流转不能做成任意跳转。待接单的工单不能直接变成待取件,检测中的工单也不能跳过“待用户确认”直接进入维修。常见做法是在代码里维护一个允许流转规则表,用 Map 或枚举定义合法路径。把校验逻辑集中在一个类里,而不是在每个 Service 方法里写 if 判断,这样后续加状态时只改一处。

状态变更日志也是一定要做的。记录工单号、旧状态、新状态、操作人、操作时间。维修行业最容易扯皮的是“顾客说手机之前还能开机,技师说送来时就开不了机”,有日志才能还原过程。这个细节在课程设计答辩时非常加分,老师会认为你想到了真实交付才有的问题。

2.3 用 E-R 关系把三个核心模块串起来

整个系统的最小闭环需要五张表:客户表、技师表、工单表、配件表、工单配件明细表。看关系:

关系类型说明
客户 - 工单1 对 N一个客户可有多张工单,一张工单只属于一个客户
技师 - 工单1 对 N一个技师可处理多张工单,一张工单只指派一个技师
工单 - 配件N 对 N通过工单配件明细表关联,明细中记录数量和结算价
配件 - 库存1 对 1库存字段直接挂在配件表上,不需要单独建库存流水表

建表时外键约束和索引要一起加。很多源码在实体类里写了关联关系,数据库里却没有外键,删除客户时工单变成孤儿数据。代码里做逻辑校验是必要的,但外键约束是底线,不然别人导入你的建表脚本后数据随便删。

还有一个容易被忽视的表:维修记录表。它与工单是 1 对 N 关系,一张工单可以追加多条检测记录,比如“检测发现主板短路,更换电容后正常”。把维修记录做成独立表而不是在工单表上堆字段,以后统计高频故障类型就非常方便。这个细节就是源码设计里“可扩展性”的具体体现,比空谈设计模式更有说服力。

3. 技术选型与源码结构:为什么用 Spring Boot + MyBatis 而不是 SSH

3.1 选型理由:课程设计与真实交付之间的平衡点

看到“基于 Java”,老项目习惯用 Servlet + JSP 或 SSH。我的建议是直接选 Spring Boot + MyBatis + MySQL + Thymeleaf。原因很实际:Spring Boot 内嵌 Tomcat,别人拿到源码不用单独配 Tomcat,双击启动类就能跑,交作业和现场演示的成功率高很多;MyBatis 的 SQL 直白,适合业务逻辑清晰的管理系统,一个维修系统本质上就是一堆 CRUD 加少量统计,JPA 反而要把精力花在关联映射上,对新手不友好;Thymeleaf 做服务端渲染,不用额外启动前端工程,这对课程设计是最低门槛的组合。

版本选择上有个血泪经验:不要追最新版。Spring Boot 3.x 要求 JDK 17,而很多学校的机器还停在 JDK 8,跑不起来就是大翻车。用 Spring Boot 2.7.x + JDK 8 是最稳的组合,Java 环境变量配置也简单,网上找资料全是现成的。等以后工作用到新版本再迁移,成本也不高。

3.2 源码包里的目录结构:一个可复现的最小工程

源码包用 Maven 标准目录结构,一个可复现的最小工程长这样:

src/main/java/com/example/repair ├── controller │ ├── WorkOrderController.java │ ├── CustomerController.java │ ├── PartController.java │ └── StatisticController.java ├── service │ ├── WorkOrderService.java │ ├── CustomerService.java │ ├── PartService.java │ └── WorkOrderStatusService.java ├── mapper │ ├── WorkOrderMapper.java │ ├── CustomerMapper.java │ ├── PartMapper.java │ └── WorkOrderPartMapper.java ├── entity │ ├── WorkOrder.java │ ├── Customer.java │ ├── Part.java │ └── WorkOrderPart.java └── config └── WebConfig.java

这个结构的核心是分层清晰。Controller 只负责接收参数和返回页面,Service 层写业务判断,Mapper 层操作数据库。不要在一个类里写完所有功能,代码量看着大,答辩时却没有一条清晰的主线可以讲。把状态校验放在 Service 层而不是 Controller 层,这也是 Java 面试题里常问的“业务逻辑放哪一层”的标准答案。

3.3 数据库建表脚本:五张表把业务闭环串起来

下面是可运行的最小建表脚本,字段设计都是踩过坑之后的版本。

CREATE TABLE customer ( id BIGINT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(50) NOT NULL, phone VARCHAR(20) NOT NULL UNIQUE, wechat VARCHAR(50), created_at DATETIME DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE technician ( id BIGINT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(50) NOT NULL, skill_tags VARCHAR(255) COMMENT '擅长维修的设备类型' ); CREATE TABLE work_order ( id BIGINT PRIMARY KEY AUTO_INCREMENT, order_no VARCHAR(32) UNIQUE NOT NULL, customer_id BIGINT NOT NULL, technician_id BIGINT, device_type VARCHAR(30) NOT NULL COMMENT '手机/笔记本/平板', brand_model VARCHAR(100), fault_desc VARCHAR(500), order_status TINYINT DEFAULT 0 COMMENT '0待接单 1检测中 2待确认 3维修中 4待取件 5已取件 6已关闭', income_amount DECIMAL(10,2) DEFAULT 0, cost_amount DECIMAL(10,2) DEFAULT 0, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, finish_time DATETIME, FOREIGN KEY (customer_id) REFERENCES customer(id), FOREIGN KEY (technician_id) REFERENCES technician(id) ); CREATE TABLE part ( id BIGINT PRIMARY KEY AUTO_INCREMENT, part_name VARCHAR(100) NOT NULL, stock INT NOT NULL DEFAULT 0, purchase_price DECIMAL(10,2) NOT NULL, sale_price DECIMAL(10,2) NOT NULL ); CREATE TABLE work_order_part ( id BIGINT PRIMARY KEY AUTO_INCREMENT, order_id BIGINT NOT NULL, part_id BIGINT NOT NULL, quantity INT NOT NULL DEFAULT 1, price DECIMAL(10,2) NOT NULL COMMENT '结算时实际使用的单价', FOREIGN KEY (order_id) REFERENCES work_order(id), FOREIGN KEY (part_id) REFERENCES part(id) );

三个设计点值得展开。第一,order_no 用字母加时间戳生成,不要直接用自增主键当工单号,它会暴露业务量,而且后期做对外查询时要拼接固定位数的单号,自增主键不够用。第二,order_status 用数字存没问题,但代码里必须对应枚举,这个坑在第 5 章详细说。第三,work_order_part 里的 price 单独保存一份,不要直接查 part 表的 sale_price,因为结算时可能打折,明细表里保留历史快照,改价不污染配件表。

4. 把维修工单流程跑通:从建单到结单的核心代码

4.1 创建工单:订单状态怎么定,配件明细怎么拆

先看创建工单的 Service 方法,这是整套源码最核心的入口。

@Override @Transactional public Long createOrder(WorkOrderCreateRequest request) { Customer customer = customerMapper.findByPhone(request.getPhone()); if (customer == null) { customer = new Customer(request.getName(), request.getPhone()); customerMapper.insert(customer); } WorkOrder order = new WorkOrder(); order.setOrderNo(generateOrderNo()); order.setCustomerId(customer.getId()); order.setDeviceType(request.getDeviceType()); order.setBrandModel(request.getBrandModel()); order.setFaultDesc(request.getFaultDesc()); order.setOrderStatus(0); workOrderMapper.insert(order); for (PartItem item : request.getParts()) { Part part = partMapper.findById(item.getPartId()); if (part == null || part.getStock() < item.getQuantity()) { throw new BusinessException("配件库存不足: " + item.getPartName()); } workOrderPartMapper.insert(order.getId(), part.getId(), item.getQuantity(), part.getSalePrice()); } return order.getId(); }

三个关键点必须讲明白。第一个是客户复用逻辑,同一手机号不新建客户记录,这是统计报表不虚高、不出现重复档案的前提。第二个是 @Transactional 注解,它保证“插入工单 + 插入多条配件明细”要么全部成功要么全部回滚,否则会出现工单建了但明细缺失的脏数据。第三个是配件校验必须在插入明细前做,把“库存不足”这个异常抛在事务内,事务回滚就不会留下半截工单。

generateOrderNo() 的常见实现是取当天日期加随机数:

private String generateOrderNo() { String datePart = LocalDate.now().format(DateTimeFormatter.BASIC_ISO_DATE); String randomPart = String.format("%04d", new Random().nextInt(10000)); return "WO" + datePart + randomPart; }

orderStatus 初始值用 0,对应状态机里的“待接单”。注意状态默认值和数据库默认值要保持一致,不要约等于 1 却在建表时写 DEFAULT 0。

4.2 维修进度流转:状态机让状态不乱跳

状态流转的规则,写在一个专门的状态类里。

public class WorkOrderStatus { public static final Map<Integer, Set<Integer>> ALLOWED_TRANSITIONS = new HashMap<>(); static { ALLOWED_TRANSITIONS.put(0, new HashSet<>(Arrays.asList(1, 6))); ALLOWED_TRANSITIONS.put(1, new HashSet<>(Arrays.asList(2, 6))); ALLOWED_TRANSITIONS.put(2, new HashSet<>(Arrays.asList(3, 6))); ALLOWED_TRANSITIONS.put(3, new HashSet<>(Arrays.asList(4))); ALLOWED_TRANSITIONS.put(4, new HashSet<>(Arrays.asList(5))); } public static boolean canChange(int current, int target) { Set<Integer> targets = ALLOWED_TRANSITIONS.get(current); return targets != null && targets.contains(target); } }

状态机设计的好处是规则集中管理,而不是散落在各个 Service 方法里。检测中(1)可以跳到待用户确认(2),用户不修就跳已关闭(6),但检测中不能直接跳到维修中(3),必须先等客户确认报价。把规则放在 Map 里后,后续业务扩展出“返厂维修”状态,只需要在这一个类里加规则,业务代码不用动。

实际调用的流程长这样:

public void updateStatus(Long orderId, int targetStatus, Long operatorId) { WorkOrder order = workOrderMapper.findById(orderId); if (order == null) { throw new BusinessException("工单不存在"); } if (!WorkOrderStatus.canChange(order.getOrderStatus(), targetStatus)) { throw new BusinessException("非法状态流转: " + order.getOrderStatus() + " -> " + targetStatus); } workOrderMapper.updateStatus(orderId, targetStatus); statusLogMapper.insert(orderId, order.getOrderStatus(), targetStatus, operatorId, LocalDateTime.now()); }

向上流转时同时记录状态日志,且日志插入和状态更新在同一个事务里。这个设计回答了 Java 基础面试题里“Map 比 switch 适合处理什么场景”:规则多、会扩展、集中管理时用 Map,固定且简单时用 switch。

4.3 配件库存扣减:事务边界不能让库存变负数

扣库存的 SQL 不能先查再扣,要写成一条原子更新语句。

public boolean deductStock(Long partId, Integer quantity) { int rows = partMapper.deductStockIfEnough(partId, quantity); if (rows == 0) { throw new BusinessException("库存不足或配件已下架"); } return true; }

对应 Mapper 里的 SQL:

UPDATE part SET stock = stock - #{quantity} WHERE id = #{partId} AND stock >= #{quantity}

这条 SQL 的精髓是把“库存是否够”和“扣减库存”合并成一个原子操作。数据库的行锁保证同一时刻只有一个线程能把这一行的 stock 扣减,如果先 SELECT stock 再判断再 UPDATE,两个并发请求都读到 stock=1,各自减 1,最终库存变成 -1。在维修系统里表现为配件领走了但账上负数,月底对账怎么都对不上。

还有一个 MyBatis 的细节:update 语句的返回值在 MySQL 里,如果更新前后值相同会返回 0,所以不要用“返回值是否等于 1”判断更新成功,而是靠 WHERE 里 stock >= #{quantity} 是否命中。这一点在 java 八股文里经常被考到。

扣库存和创建工单必须放在同一个事务里。扣库存成功但建单失败,库存已经没法回滚;建单成功但扣库存失败,工单成了没有配件的空单。两者都用 @Transactional 包住,任何异常都会触发统一回滚。

5. 落地踩坑记录:这些坑不避开,源码能跑但项目交不了

5.1 时间字段变成 null:LocalDateTime 与 MySQL datetime 的映射坑

现象:工单创建后时间字段正常,但更新完成时间后再次查询,finish_time 变成 null。

原因:MyBatis 对 LocalDateTime 的映射依赖数据库驱动版本。老版本 mysql-connector-java 5.x 对 JSR310 时间类型的支持不完整,写入时可能被忽略,读取时类型转换出错被吞掉。更隐蔽的是有些源码里用 Date 类型接收,页面传的是字符串,格式化失败时字段直接变成 null。

解决:mysql-connector-j 升到 8.0.x,实体类时间字段统一用 java.time.LocalDateTime,不要混用 java.util.Date。数据库连接串里加上 serverTimezone=Asia/Shanghai 和 useSSL=false,避免时区差异把时间存偏,查询出来又是另一个值。

5.2 库存超卖:先查后扣的并发问题

现象:明明库存剩余 1 个屏幕,两个维修工单同时录入,都显示库存足够,最后库存变成 -1。

原因:先 SELECT stock 再判断再 UPDATE,两个并发事务同时读到同一个库存值,各自判断通过,最终都执行扣减,超卖就发生了。维修系统并发量虽然不如电商秒杀,但门店连网后两台电脑同时开单就可能触发。

解决:用 UPDATE ... WHERE stock >= #{quantity} 的原子扣减。数据一致性靠数据库约束兜底,不要只靠 Java 代码的逻辑判断。以后这个系统要去掉并发隐患,把库存扣减放到独立事务或引入乐观锁,那都是后话,先用原子 SQL 保住不超卖。

5.3 金额用 double 还是 BigDecimal:账目对不上就出在这

现象:统计维修收入时,换个屏幕 480 元,换个电池 99.9 元,加在一起显示 579.8999999。

原因:double 是二进制浮点计数,0.1 + 0.2 算出来不是精确的 0.3。维修系统里每个字段都涉及钱,int 和 double 看着省事,累计报表一旦对不上账,问题找起来最痛苦。

解决:实体类里用 BigDecimal,MySQL 里用 DECIMAL(10,2)。BigDecimal 构造不要用 new BigDecimal(0.1),要用 BigDecimal.valueOf(0.1) 或字符串构造,否则精度问题原样保留。前端展示统一调用 setScale(2, RoundingMode.HALF_UP) 后再输出,JSON 序列化时如果用的是 fastjson,注意它默认会把 BigDecimal 转成字符串,类型就对不上了。

5.4 状态字段用 int 还是 varchar:改需求时最痛的后悔药

现象:原本 order_status 只有 0 到 5 六个值,后来要加一个“待返厂维修”,代码里所有 switch 判断都要翻一遍,漏改一处,状态就卡在中间位置。

原因:用数字魔法值表达状态,阅读代码的人必须对照注释才能看懂每个数字的含义。新增状态时,散落的 if-else 和 switch 分支都要同步改,漏一处就是隐蔽 Bug。

解决:数据库里用数字存没问题,但 Java 代码里定义枚举类,所有状态判断用枚举的 name 或 code,不写裸数字。加新状态时只需要改状态机的 ALLOWED_TRANSITIONS Map 和枚举定义,业务代码基本不用动。这个后悔药值得备着,因为维修系统的状态大概率会随着业务扩张而增加。

5.5 有外键关联的表删除时莫名报错

现象:删除一条客户记录时,弹出外键约束错误,但代码里明明没有调用删除工单的接口。

原因:建表脚本里有 FOREIGN KEY 约束,work_order 表引用了 customer 表的 id,直接删除客户会触发数据库层的约束检查,把删除操作挡住。

解决:不要关闭外键约束,那等于自毁底线。正确的做法是业务上不允许物理删除有历史工单的客户,改成逻辑删除,给 customer 表加一个 is_deleted 字段,删除时置 1,查询时默认过滤掉已删除客户。这样既保留历史数据,又解决外键报错。反过来,如果某天想清理测试数据,按父子关系先删子表再删父表,工具里提供这个接口,演示时不会尴尬。

6. 这套源码的进阶玩法:把统计报表和微信通知接进来

6.1 维修收入与配件损耗统计:一张 SQL 撑起答辩亮点

如果源码只有增删改查,答辩时会显得单薄。花一个晚上把统计报表加上,价值立刻不一样。最有用的统计是按月份汇总维修毛利、按配件统计消耗数量、按故障类型统计频次。

SELECT DATE_FORMAT(created_at, '%Y-%m') AS month, SUM(income_amount - cost_amount) AS profit FROM work_order WHERE order_status IN (5, 6) GROUP BY DATE_FORMAT(created_at, '%Y-%m') ORDER BY month;

这条 SQL 的边界条件是只统计已取件和已关闭的工单,检测中或维修中的工单还没产生真实毛利,统计进去会误导决策。待用户确认后不修的工单要走已关闭状态,此时成本金额为空,建议用 COALESCE 兜底。把报表结果封装成一个 MonthProfitVO,Controller 返回 Thymeleaf 模板,前端用 Chart.js 画一条折线图,工作量不大,视觉效果好得多。

6.2 从课程设计到简历项目:给自己加一个亮点

给正在做这个系统的同学一个具体建议:别止步于把源码跑起来,把自己当成真实店主用一遍。录一个客户,建一张工单,走完状态流转,结单后看一眼统计报表,完整闭环能走通,项目才算交付。

微信通知是性价比最高的进阶。在取件状态变更时调用企业微信机器人 Webhook,用 Java 自带 HttpClient 发一条 JSON 消息,内容是“您的设备已维修完毕,请到店取件”。不需要申请公众号,不需要服务器域名,本地调试就能看到效果。这个功能在答辩现场展示,比几十页 PPT 有用,老师和面试官看到的不只是“我会调接口”,而是“我能在业务里合理使用外部服务”。

这么多年做 Java 项目,我一个固定习惯是:把建表脚本、初始化数据和源码目录放在同一个仓库里,二手电子产品维修系统尤其如此,因为换机器跑演示时,只拷一份打了补丁的代码远远不够,数据库脚本对不上,启动必挂。再花二十分钟写一个 README,把 JDK 版本、Java 环境变量配置、数据库创建步骤写清楚,这是源码交付的基本素养。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询