ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

Java洗衣店管理系统:从CRUD到状态机设计与事务实践

Java洗衣店管理系统:从CRUD到状态机设计与事务实践 简介这是一份基于Java的洗衣店管理系统设计与实现完整文档采用B/S结构结合JSP、Java和MySQL进行开发主要面向需要完成毕业设计或课程设计的计算机专业学生用于解决洗衣店会员、消费、清洗、修补及营业统计等信息化管理问题。文档以实际业务为背景完整呈现系统从需求分析到设计实现的思路用户端支持查询个人消费、衣物清洗、修补和赔偿记录管理员端则涵盖会员卡管理、衣物清洗/修补/赔偿管理、消费记录管理与营业额统计等核心模块。资源包为1个docx格式文件大小约3.11MB内容包含摘要、目录、绪论、开发环境介绍、系统设计、功能实现与总结等完整章节结构清晰可作为毕业设计论文模板或系统开发参考。文档对系统架构、数据库设计、关键模块划分及部分实现细节均有说明能够帮助读者快速掌握洗衣店管理系统的整体框架和开发流程。目前该资源已有154人学习下载适合正在准备相关课题的学生借鉴使用。1. 从课程设计到生产级应用Java洗衣店管理系统到底要解决什么问题洗衣店管理系统这个题目在Java课程设计和毕业设计里出现频率极高但大部分实现都停留在能增删改查的层面。真正去过洗衣店、或者和门店老板聊过就知道这个系统最核心的难点不是登记会员、不是打印小票而是衣物状态流转和按件计费的准确性。一件衣服从收进来、洗、烘、熨、挂到取走中间任何一个环节脱节都会造成找不到衣服或者多收少收钱的纠纷。所以技术上的落点其实是怎么用数据库模型和事务把这条状态链锁死。这篇文章按照我对这类管理系统的常规做法先讲清楚为什么选用某个技术组合和表结构设计思路然后给出数据库建模、核心业务代码收衣、取衣、结算、以及可以提升完成度的进阶配置。读者如果是准备做这个题目的在校生可以直接按步骤落地如果是工作多年想快速梳理类似业务系统的工程师也可以从状态机设计和事务边界这两节拿到可复用的经验。全文涉及的技术栈以Java为主适合已经能把Spring Boot跑起来、但还没做过完整业务系统的开发者。2. Java洗衣店管理系统的技术选型和工程骨架2.1 为什么不是SSH而是Spring Boot MyBatis Plus早几年这个题目常被做成SSHStruts2 Spring Hibernate或者SSMSpring SpringMVC MyBatis那时候课程设计的要求是能分层、能用框架。现在的实际环境里新开项目用Spring Boot已经是默认选择因为它的自动配置和内嵌Tomcat让部署成本大幅下降。对于洗衣店管理系统这种典型的管理类CRUD项目Spring Boot 2.x MyBatis Plus是我会用得最顺的组合。MyBatis Plus的价值在于单表CRUD不用写XML比如对member表插入一条记录直接调用memberMapper.insert(entity)就能完成。对比原生MyBatis省掉的是大量重复的insert into...语句和resultMap配置。很多教程会建议用Spring Data JPA但洗衣店业务的查询条件组合太随意比如查询今天收衣且会员等级是金卡的记录JPA需要写Query注解或者方法名推导可读性和可维护性都不如MyBatis的Select注解直接。工程骨架我习惯这样搭建com.laundry ├── controller │ ├── OrderController.java │ ├── MemberController.java │ └── ReportController.java ├── service │ ├── OrderService.java │ └── MemberService.java ├── mapper │ ├── OrderMapper.java │ └── MemberMapper.java ├── entity │ ├── Order.java │ └── Member.java ├── common │ ├── Result.java │ └── BizException.java └── LaundryApplication.javacontroller层只做参数接收和结果封装service层写业务判断mapper层做数据持久化entity层对应数据库表。这套分包方式的好处是答辩被问项目结构怎么设计的时可以很清晰地说出每一层的职责不会出现controller里直接查数据库的写法。提示不要一上来就引入Redis、MQ、分库分表。洗衣店的并发量一天撑死几百单引入这些组件只会让系统变重还会被追问你凭什么说需要Redis缓存会员信息。2.2 Maven依赖和配置文件里最容易踩的两个坑pom.xml里加依赖时有两个问题是几乎每个初做这个项目都会踩的。第一个是MyBatis Plus和Spring Boot的版本兼容。以我常用的组合为例Spring Boot 2.7.x配MyBatis Plus 3.5.3就没有问题但如果把Spring Boot升到3.x就必须用MyBatis Plus 3.5.4以上版本因为3.x的Spring Boot基于Jakarta命名空间。代码里体现出来就是javax.servlet全变成了jakarta.servlet不升级MyBatis Plus会直接报ClassNotFoundException。第二个是数据库驱动。MySQL 8.x用com.mysql.cj.jdbc.DriverMySQL 5.7用com.mysql.jdbc.Driver。如果驱动版本和数据库版本对不上启动时日志会提示Loading class com.mysql.jdbc.Driver. This is deprecated.。新版驱动还强制要求配置时区连接串里建议带上serverTimezoneAsia/Shanghai不然北京时间在JDBC里会被当成UTC时间订单创建时间和实际差了8小时。server: port: 8080 spring: datasource: url: jdbc:mysql://localhost:3306/laundry?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai username: root password: 123456 driver-class-name: com.mysql.cj.jdbc.Driver mybatis-plus: configuration: map-underscore-to-camel-case: true log-impl: org.apache.ibatis.logging.stdout.StdOutImpl global-config: db-config: logic-delete-field: deleted logic-delete-value: 1 logic-not-delete-value: 0map-underscore-to-camel-case这个配置很关键它负责把数据库字段clothes_name自动映射为实体属性clothesName否则MyBatis查出来的记录里这个字段就是null。logic-delete-field配置的是逻辑删除字段洗衣店的订单记录不可物理删除因为涉及财务对账只能通过一个deleted标记位隐藏。2.3 统一返回体和全局异常处理是拉开代码档次的第一步很多人在写这个项目时controller返回值直接用MapString, Object代码里散落着map.put(code, 200)。代码少的时候看着无所谓一旦涉及到订单和会员等多个模块这种写法会让前端对接的人想骂街。可以准备一个通用返回体保证所有接口输出格式一致public class ResultT { private Integer code; private String message; private T data; public static T ResultT success(T data) { ResultT result new Result(); result.setCode(200); result.setMessage(操作成功); result.setData(data); return result; } public static T ResultT error(String message) { ResultT result new Result(); result.setCode(500); result.setMessage(message); return result; } }接口的返回格式统一为{code, message, data}前端判断逻辑只需要写一次。code建议用HTTP语义200表示正常500表示业务异常401表示未登录认证失败避免自己发明一套状态码。全局异常处理用RestControllerAdvice配合ExceptionHandler实现。我在这个项目里只建了BizException这一个业务异常类凡是会员余额不足、订单已取衣不可取消这类预期内的问题都在service层手动抛出BizException然后由全局处理器捕获并返回给前端预期内的错误信息。至于系统异常空指针、数据库连接失败同样会被全局处理器捕获但日志打到error级别前端看到的是系统繁忙请稍后重试。注意不要把数据库主键冲突、字段超长这类底层异常直接拼接成前端提示比如Duplicate entry 1 for key PRIMARY用户看不懂还会暴露表结构。3. 设计洗衣店管理系统的数据库表状态机思维是核心3.1 六张核心表和它们之间的关系洗衣店管理系统涉及的实体不多我整理下来最少需要六张表。这里不列冗余的业务扩展表比如员工排班、设备维护只讲能让系统跑通闭环的最小表结构。表名核心字段作用memberid, phone, name, balance会员基本信息及预存款余额clothes_typeid, type_name, unit_price衣物类型及对应的基础洗衣单价ordersid, order_no, member_id, status, total_amount订单主表维护整体状态和金额order_itemid, order_id, clothes_type_id, count, amount订单明细表一件衣服一行数据laundry_processid, order_id, process_status, update_time洗衣流程表记录状态流转时间线payment_recordid, order_id, pay_amount, pay_time支付记录表每笔订单的实付金额member和orders是一对多一个会员可以下多单orders和order_item是一对多一单可以包含多件不同价格的衣服order_item和laundry_process一般情况下是一对一也可以设计成一单一个流程记录这取决于门店是按件跟踪还是按单跟踪。如果只关心这单到哪一步了那laundry_process和orders直接关联即可。clothes_type一定要独立建表不要用系统内置的MapInteger, String传参。因为洗衣的价格是会调整的今天羽绒服30元下个月可能因成本上涨调到35元。价格做成配置项改价格不用发版运营人员在数据库改完前端下拉框展示的就是新价格历史订单的价格存在order_item.amount里不受影响。3.2 用SQL定义订单表和流程表订单表的建表语句我通常这样写CREATE TABLE orders ( id bigint(20) NOT NULL AUTO_INCREMENT, order_no varchar(32) NOT NULL COMMENT 订单号, member_id bigint(20) NOT NULL COMMENT 会员ID, status tinyint(4) NOT NULL DEFAULT 0 COMMENT 状态0已收衣 1洗涤中 2已烘干 3已熨烫 4待取衣 5已完成 6已取消, total_amount decimal(10,2) NOT NULL COMMENT 订单总金额, remark varchar(255) DEFAULT NULL COMMENT 备注, deleted tinyint(1) NOT NULL DEFAULT 0 COMMENT 逻辑删除, create_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, update_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_order_no (order_no), KEY idx_member_id (member_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT洗衣订单主表;订单状态这里用了tinyint而不是varchar存的是数字0到6查出来时在Java侧通过枚举类映射为中文。好处有两点一是数据库存储占用极小二是排序和比较的效率高。但代价是看数据时不能一眼看懂需要翻枚举定义。这个问题可以在开发环境通过查询语句解决SELECT order_no, CASE status WHEN 0 THEN 已收衣 WHEN 1 THEN 洗涤中 WHEN 2 THEN 已烘干 WHEN 3 THEN 已熨烫 WHEN 4 THEN 待取衣 WHEN 5 THEN 已完成 WHEN 6 THEN 已取消 END AS status_name FROM orders;order_no需要设定唯一键。不要直接用数据库自增id当作订单号给用户看因为这样就暴露了平台一天的订单量且不方便按日期检索。我一般会拼成yyyyMMdd 4位自增序列当天首单为202506120001第二单为202506120002。生成逻辑放在service层先查当天已有最大单号的后四位加一后拼接不需要引入分布式锁因为洗衣店不存在多节点同时下单的高并发。order_item表相对简单核心字段是clothes_type_id和count。这里要强调的是amount字段必须存冗余快照值而不是每次查询时联表用clothes_type.unit_price * count计算。因为clothes_type表的价格可能会调整若不做快照一个月前的订单在月底对账时算出来的金额就变了。CREATE TABLE order_item ( id bigint(20) NOT NULL AUTO_INCREMENT, order_id bigint(20) NOT NULL, clothes_type_id bigint(20) NOT NULL, clothes_name varchar(50) NOT NULL COMMENT 衣物名称冗余, count int(11) NOT NULL DEFAULT 1, unit_price decimal(10,2) NOT NULL COMMENT 下单时单价快照, amount decimal(10,2) NOT NULL COMMENT 金额快照unit_price*count, PRIMARY KEY (id), KEY idx_order_id (order_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT订单明细表;3.3 状态机设计用一张图想清所有状态和允许的流转路径洗衣店管理系统的业务本质是状态机。收衣是初始态取衣是终止态中间的洗涤、烘干、熨烫是必经中间态取消是异常终止态。如果不在代码里显式限制状态流转用户就可能点着按钮把一件衣服从已收衣直接跳成待取衣数据链条就废了。不引入额外状态机框架用Java枚举就可以把这套逻辑固化下来public enum OrderStatus { RECEIVED(0, 已收衣), WASHING(1, 洗涤中), DRYING(2, 已烘干), IRONING(3, 已熨烫), READY(4, 待取衣), COMPLETED(5, 已完成), CANCELED(6, 已取消); private final int code; private final String desc; public static boolean canTransit(int from, int to) { if (from CANCELED.code || from COMPLETED.code) { return false; } if (from RECEIVED.code) { return to WASHING.code || to CANCELED.code; } if (from WASHING.code) { return to DRYING.code; } if (from DRYING.code) { return to IRONING.code; } if (from IRONING.code) { return to READY.code; } if (from READY.code) { return to COMPLETED.code; } return false; } }canTransit方法将每个状态能到达的下一个状态写死。比如已收衣只能去洗涤中或已取消不允许直接跳待取衣。使用这个枚举更新状态时更新条件里必须带上当前状态防止多人同时操作导致状态覆盖int rows orderMapper.updateStatusIfCurrentStatus(orderId, fromStatus, toStatus); if (rows 0) { throw new BizException(订单状态已变更请刷新后重试); }这个rows 0的判断很重要。设想有两个员工同时操作系统A把单子从洗涤中改为已烘干B同时把同一单改为已取消。如果没有这个条件判断后执行的更新会直接把前一个更新覆盖掉最后页面上显示的状态与实物状态对不上。加上WHERE status #{fromStatus}后只有一个更新能成功另外一个更新0行直接抛出异常让操作者感知到冲突。4. 核心代码实现收衣、取衣、结算三个场景4.1 收衣流程批量明细写入和事务边界收衣的操作是前端扫描衣物条码选择会员选择衣物类型输入件数提交后生成订单主记录和明细记录。前端的活不多真正有含量的是在用事务保证主表和明细表同时写入成功。如果在写订单主表成功后、写明细表时发生异常没有事务的话就会出现一个没有明细的空订单。这会被老板质疑衣服没收进来系统上却挂着单子。service层的代码骨架如下Transactional(rollbackFor Exception.class) public Long createOrder(OrderCreateDTO dto) { // 1. 生成订单号 String orderNo generateOrderNo(LocalDate.now()); // 2. 遍历明细计算总额 BigDecimal totalAmount BigDecimal.ZERO; for (OrderItemDTO item : dto.getItems()) { ClothesType type clothesTypeMapper.selectById(item.getClothesTypeId()); if (type null) { throw new BizException(不存在的衣物类型: item.getClothesTypeId()); } BigDecimal amount type.getUnitPrice().multiply(BigDecimal.valueOf(item.getCount())); totalAmount totalAmount.add(amount); } // 3. 插入主表 Order order new Order(); order.setOrderNo(orderNo); order.setMemberId(dto.getMemberId()); order.setStatus(OrderStatus.RECEIVED.getCode()); order.setTotalAmount(totalAmount); orderMapper.insert(order); // 4. 插入明细表 for (OrderItemDTO item : dto.getItems()) { ClothesType type clothesTypeMapper.selectById(item.getClothesTypeId()); OrderItem orderItem new OrderItem(); orderItem.setOrderId(order.getId()); orderItem.setClothesTypeId(type.getId()); orderItem.setClothesName(type.getTypeName()); orderItem.setCount(item.getCount()); orderItem.setUnitPrice(type.getUnitPrice()); orderItem.setAmount(type.getUnitPrice().multiply(BigDecimal.valueOf(item.getCount()))); orderItemMapper.insert(orderItem); } return order.getId(); }Transactional(rollbackFor Exception.class)是最容易被忽略的细节。Spring默认只对RuntimeException回滚如果抛出的异常继承自Exception但不属于RuntimeException比如FileNotFoundException事务不会回滚。在这个业务里虽然大概率只会抛BizException继承RuntimeException但写成rollbackFor Exception.class更稳妥保证任何异常都回滚。另外注意金额计算用的是BigDecimal而不是double。洗衣价格精确到分double的浮点误差在多次累加后会暴露比如0.1加0.2得到0.30000000000000004。数据库的decimal类型在Java里最自然的映射就是BigDecimal所以从entity到计算全链路保持一致。4.2 取衣流程状态校验、余额扣减、支付流水三件事必须同时发生取衣是这个系统里业务规则最杂的逻辑。一次性处理三件事订单状态从待取衣变成已完成如果选用会员卡余额支付扣减会员余额插入支付流水记录。这三件事必须在一个事务里完成否则可能出现订单已完结但钱没扣到或者钱扣了但订单还是待取衣属于小概率但后果严重的事故。Transactional(rollbackFor Exception.class) public void completeOrder(Long orderId, Long memberId) { // 1. 订单校验 Order order orderMapper.selectById(orderId); if (order null) { throw new BizException(订单不存在); } if (order.getStatus() ! OrderStatus.READY.getCode()) { throw new BizException(订单当前状态不允许取衣); } // 2. 余额扣减带版本号或行锁的更新 int rows memberMapper.deductBalance(memberId, order.getTotalAmount()); if (rows 0) { throw new BizException(余额不足或会员不存在); } // 3. 更新订单状态 int updateRows orderMapper.updateStatusIfCurrentStatus(orderId, OrderStatus.READY.getCode(), OrderStatus.COMPLETED.getCode()); if (updateRows 0) { throw new BizException(订单状态已变化请刷新); } // 4. 插入支付记录 PaymentRecord record new PaymentRecord(); record.setOrderId(orderId); record.setPayAmount(order.getTotalAmount()); record.setPayTime(LocalDateTime.now()); paymentRecordMapper.insert(record); }这里memberMapper.deductBalance的SQL写法是一个可以引导答辩的亮点UPDATE member SET balance balance - #{amount} WHERE id #{memberId} AND balance #{amount}不要先select余额再判断够不够再update两个操作之间如果有并发请求会出现超扣。把条件直接写进UPDATE语句数据库层面的行锁会串行化这个操作余额不够时影响行数为0代码层就能感知。注意deductBalance和updateStatusIfCurrentStatus的顺序有讲究。先扣钱、再改状态、最后插流水这个顺序意味着如果后面任一步失败前面的扣钱会因事务回滚而撤销。如果把改状态放在最后一步也是在保护钱有变动的数据。4.3 报表统计这个月收入多少、什么衣服洗得最多管理系统的价值一半体现在报表上。洗衣店老板不关心订单列表有多漂亮他关心的是这个月营业额多少、和上个月比是涨是跌、哪种衣服送洗最多。用一条SQL就能拿到SELECT DATE_FORMAT(pay_time, %Y-%m) AS month, COUNT(DISTINCT order_id) AS order_count, SUM(pay_amount) AS total_revenue FROM payment_record WHERE pay_time 2025-01-01 AND pay_time 2026-01-01 GROUP BY DATE_FORMAT(pay_time, %Y-%m) ORDER BY month;分组统计月份维度时DATE_FORMAT的效率在数据量达到十万级以前没有问题。如果量再大可以考虑在payment_record表为pay_time建索引后把SQL优化为按pay_time BETWEEN ? AND ?的范围扫描。统计衣物类型占比可以用类似方式GROUP BYclothes_name前端用ECharts画饼图老板一眼就能看出小店的核心营收品类是什么。5. 提高系统完成度从课程设计到能落地的小细节5.1 多门店扩展要提前埋好的冗余字段很多洗衣店不止一个门面就算当前题目没提也可能被问到如果开分店了怎么改。与其到时候改表结构不如在设计初期就加上store_id字段把订单、会员、支付流水全部和门店关联。加一个字段的成本极低但为后面的扩展留下了空间。查询时只需要在SQL里带上WHERE store_id ?即可报表可以按门店维度汇总对比看哪家店把洗坏衣服的赔付率拉到最高也算是一个经营洞察。5.2 一个可复用的流水号生成器项目接近完成时你可能已经用了至少三处流水号订单号、支付流水号、会员编号。每一处都单独写一套生成逻辑是一种常见的脏代码模式。我通常会把生成逻辑抽成独立组件Component public class SerialNumberGenerator { private final StringRedisTemplate redisTemplate; public String generate(String prefix, LocalDate date) { String key serial: prefix : date; Long seq redisTemplate.opsForValue().increment(key); if (seq ! null seq 1L) { redisTemplate.expire(key, Duration.ofDays(1)); } return String.format(%s%s%04d, prefix, date.format(DateTimeFormatter.BASIC_ISO_DATE), seq); } }这里用Redis做自增序列好处是不需要每次查数据库的max值也不用担心两节点同时生成同一编号Redis的单线程特性天然保证原子性。如果不想引入Redis依赖也可以用SELECT MAX(order_no) FROM orders WHERE order_no LIKE 前缀%加行锁的方式但并发下性能和可靠性都不如Redis方案。5.3 答辩时值得讲的三个细节第一个是数据库隔离级别与行锁。在取衣流程里用到的balance amount条件更新本质上是悲观锁思路。可以在答辩时说明为什么不用乐观锁CAS——因为洗衣店系统的冲突概率极低用乐观锁会引入重试逻辑而悲观锁更简单且MySQL的当前读本身就带锁不会造成死锁。第二个是逻辑删除与唯一索引的冲突。给订单加了deleted字段后如果还保持着uk_order_no唯一索引那么同一订单号被逻辑删除后无法再插入相同订单号。解决方法是把deleted放进唯一索引里变成(order_no, deleted)组合唯一索引这样不同记录可以用deleted1和deleted0区分。这是一个可以让面试官眼前一亮的细节。第三个是前端页面的必选配置。接口后端做好了前端建议用Vue Element UI登录页、订单管理页、报表页三大页面足够展示完整全栈能力。页面不用多但要有交互比如订单状态的级联下拉选择框选择洗涤中后仅展示可流转到已烘干的按钮。前端约束和后端状态机校验共同保证数据合规这样项目的完整度远高于只做一个后台管理接口集合。至此从技术选型、数据库建模、核心业务实现到扩展性设计一套基于Java的洗衣店管理系统的设计与实现路径已经完整铺开。如果照着做收获的不仅是一个能运行的答题项目还有应对类似管理系统的通用方法论状态机约束数据流转事务保证多表一致性快照字段保证历史可追溯。本文还有配套的精品资源点击获取
返回列表