ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

洗衣店管理系统实战:订单状态机与计费规则的设计与实现

洗衣店管理系统实战:订单状态机与计费规则的设计与实现 简介基于Java洗衣店管理系统设计与实现是一份面向计算机相关专业毕业设计参考的完整论文文档系统采用B/S结构基于JSP技术、Java语言和MySQL数据库开发为用户提供消费记录、衣服清洗、修补、赔偿查询为管理员提供会员卡、订单及营业额统计等管理功能。文件为单个docx格式共32页约3.11MB内容涵盖摘要、目录、绪论、系统开发环境、需求分析、系统设计等完整章节详细说明了系统架构、数据库管理、用户界面及安全性、可扩展性等设计要点并贯穿“操作简单功能实用”的设计理念。已有154人学习/下载适合正在选题或撰写洗衣店管理系统类毕设论文的读者参考可帮助快速梳理论文结构与技术路线也能为JSPJavaMySQL项目开发与文档撰写提供范例。1. 洗衣店管理系统不是增删改查先想清楚它和图书管理系统的本质区别一个基于 Java 的洗衣店管理系统听起来像是典型的课设题目——无非是客户表、订单表、商品表配上几个增删改查页面。但真做过的人会告诉你洗衣店业务里藏着两个让新手集体翻车的东西计费规则和衣物状态流转。按件洗、按重量洗、会员折扣、取衣时限、逾期保管费这些规则叠在一起订单表设计不好后面每加一个功能都要改表结构。再加上洗衣进度要从「收衣」走到「上机」「洗涤」「烘干」「整烫」「取衣」六个状态任何一步漏了记录门店对账就变成玄学。这个管理系统适合两类人一类是做 Java 课程设计或毕业设计的学生需要在一个真实业务场景里展示面向对象建模、分层架构和数据库设计能力另一类是真正要帮小洗衣店做信息化的小团队需要一个能跑在普通电脑上、不需要高成本服务器、店员半天就能学会使用的系统。本文按我做过的一个方案讲Java Swing/JavaFX 做桌面端MySQL 存数据MyBatis 管持久层订单状态机控制整个洗衣流程。这套组合不需要复杂的分布式环境一台 Windows 电脑就能开发调试数据一致性用本地事务加乐观锁就能解决部署时用启动脚本一键拉起。2. 订单状态机与计费规则建表之前先把业务闭环画清楚2.1 六状态流转为什么必须用状态机而不是一个状态字段很多初学方案里洗衣订单就一个status字段用数字 0 到 5 表示不同阶段。看起来没问题但真实业务里状态不是随便跳的——你不能从「收衣」直接跳到「取衣」也不能在「洗涤中」把衣物标记为「已上机」因为每一步都关联着操作员、时间和实物位置。直接在 Service 层写if (status 1) { status 2; }的后果是三个月后加新功能的人根本不知道哪些跳转是合法的改一处崩三处。我一般先把状态流转图画出来再写代码。六个状态定义如下0 已收衣前台录入衣物信息生成取衣小票1 已上机衣物进入洗涤设备记录设备编号2 洗涤中设备运行中预计完成时间3 已烘干烘干完成进入整烫环节4 已整烫衣物整理完毕等待取衣5 已取衣客户取走衣物订单关闭状态机的核心价值在于把跳转规则收拢到一个地方。用 Java 枚举加一张跳转表比散落在各个 Service 方法里的 if-else 好维护得多。后面想加「返洗」状态只需要在枚举里加一个值跳转表里多写两条边不用去翻所有调用方。public enum OrderStatus { RECEIVED(0, 已收衣), ON_MACHINE(1, 已上机), WASHING(2, 洗涤中), DRIED(3, 已烘干), IRONED(4, 已整烫), PICKED_UP(5, 已取衣); private final int code; private final String desc; // 合法跳转表当前状态 - 可以跳转到的状态集合 private static final MapOrderStatus, SetOrderStatus TRANSITIONS new HashMap(); static { TRANSITIONS.put(RECEIVED, EnumSet.of(ON_MACHINE, PICKED_UP)); // 收衣后可直接取衣未洗涤如只熨烫 TRANSITIONS.put(ON_MACHINE, EnumSet.of(WASHING)); TRANSITIONS.put(WASHING, EnumSet.of(DRIED, WASHING)); // WASHING 允许自跳用于延长洗涤时间 TRANSITIONS.put(DRIED, EnumSet.of(IRONED)); TRANSITIONS.put(IRONED, EnumSet.of(PICKED_UP)); TRANSITIONS.put(PICKED_UP, EnumSet.noneOf(OrderStatus.class)); // 终态 } public boolean canTransitionTo(OrderStatus target) { SetOrderStatus allowed TRANSITIONS.get(this); return allowed ! null allowed.contains(target); } }这段枚举解决的不只是「合法跳转」问题它还顺带让业务规则可以复用。比如洗衣店的实际场景中有只熨烫不水洗的订单那么状态路径就是「已收衣 - 已整烫 - 已取衣」跳转表里 RECEIVED 到 IRONED 这条边就是为这个场景留的。另一个细节是 WASHING 允许自跳因为大件衣物洗涤时间可能超过预设值操作员需要延长状态停留时间。2.2 计费规则的三种模型按件、按重量、按会员等级计费是整个系统里最容易改出 bug 的地方。常见的洗衣店有三种计费方式它们不是互斥的而是会叠加按件计费每类衣物有固定单价比如衬衫 15 元、羽绒服 45 元。这种方式需要一张「衣物类型表」存类型名称、单价、预计洗涤时长。按重量计费往往用于床品、窗帘进店先称重每公斤单价乘以重量。重量要留小数点后两位系统里用DECIMAL(5,2)存避免浮点误差。会员折扣会员卡分等级普通会员 9 折、金卡 8 折、黑金卡 7 折同时还有充值赠送规则。折扣落在订单明细级别而不是订单总额级别因为洗衣店经常出现「整单里部分衣物不打折」的情况。订单表里如果只存一个total_price后面统计会员消费、按衣物类型汇总收入都会变得很难做。我的做法是拆三层订单主表存客户、状态、应收总额订单明细表存每件衣物的类型、数量、单价、折扣率支付记录表存实收金额和支付方式。折扣只在明细节上算主表的应收总额是明细的聚合结果。-- 订单明细表每一件衣物一行 CREATE TABLE order_item ( id BIGINT PRIMARY KEY AUTO_INCREMENT, order_id BIGINT NOT NULL, clothes_type_id BIGINT NOT NULL COMMENT 关联衣物类型表, clothes_name VARCHAR(50) NOT NULL COMMENT 冗余衣物名称防止类型表改名影响历史订单, quantity INT NOT NULL DEFAULT 1, unit_price DECIMAL(6,2) NOT NULL COMMENT 单价来自衣物类型表下单时快照, discount_rate DECIMAL(3,2) NOT NULL DEFAULT 1.00 COMMENT 0.80表示八折, item_total DECIMAL(8,2) NOT NULL COMMENT 小计 数量 * 单价 * 折扣率, remark VARCHAR(200) COMMENT 污渍、破损等特殊说明, PRIMARY KEY (id), KEY idx_order_id (order_id) );clothes_name字段冗余存储是有意为之——衣物类型表里的价格可以调整但历史订单的明细应当保持下单时的快照不能跟着类型表变动。用DECIMAL而不是float/double是账务系统的常识浮点数存金额会出现 0.1 0.2 0.30000000000000004 的问题Java 侧配合BigDecimal使用。订单主表里应收总额不直接暴露给 Service 层随意 set而是由明细汇总而来。可以在 Service 层计算也可以用触发器但为了便于排查问题我倾向在事务里用 Java 代码计算后写入同时保留一个核对 SQL 用于定期检查主表和明细表是否对得上。计费规则中的「逾期保管费」是另一个经典坑衣物洗好超过 30 天未取每天加收 2 元。这个规则不要在查询时实时算而是在取衣时用定时任务扫描「待取衣超过 N 天」的订单生成逾期费用记录前台取衣时看到的是已经算好的金额。2.3 数据库建模五张核心表的主键设计与关联关系整个系统最少需要五张核心表客户表、衣物类型表、订单主表、订单明细表、支付记录表。如果还要管会员卡余额则再加一张会员卡表和一张充值流水表。主键我统一用BIGINT AUTO_INCREMENT不做分布式主键——单店场景不需要雪花算法带了 UUID 反而让索引变慢。客户表和会员卡表为什么要分开因为洗衣店存在「非会员临时洗」的场景。第一次来洗衣服的顾客可能只洗一件衬衫不想办卡但他仍然要在系统里留下联系方式方便通知取衣。所以客户表是基础信息会员卡是扩展信息两者一对一但允许客户没有卡。订单表通过customer_id关联客户而不是关联会员卡这样非会员订单也能正常走流程。CREATE TABLE customer ( id BIGINT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(50) NOT NULL, phone VARCHAR(20) NOT NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, KEY idx_phone (phone) ); CREATE TABLE membership_card ( id BIGINT PRIMARY KEY AUTO_INCREMENT, customer_id BIGINT NOT NULL UNIQUE, card_no VARCHAR(32) NOT NULL UNIQUE, level TINYINT NOT NULL DEFAULT 1 COMMENT 1普通 2金卡 3黑金, balance DECIMAL(10,2) NOT NULL DEFAULT 0.00, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, KEY idx_card_no (card_no) );会员卡余额是一个高频更新字段。每次消费如果走余额支付就是一次「读余额 - 扣余额 - 写回去」的操作。单机场景下用数据库行锁就能解决不需要引入 Redis 分布式锁。但要注意扣余额必须发生在数据库事务里而不是先在 Java 内存里算好再更新否则两个窗口同时操作一张卡就会出现余额被多扣或变成负数的问题。这个点在第四章展开讲。订单表和明细表的关系是典型的一对多。查询订单详情时用JOIN order_item查出所有衣物行然后组装成一个订单 VO 对象返回给界面层。订单状态放在主表里明细表不带状态——整单状态是一致的不存在「同一单里一件洗好了另一件还没」的情况。如果以后要做「按件取衣」那要拆分订单或者给明细加状态属于另一个量级的业务复杂度初版不要碰。3. 用 Java 实现核心业务流程收衣下单、进度流转、取衣结算3.1 收衣下单事务明细校验与会员折扣的落库顺序收衣是洗衣店每天最频繁的操作这个动作在一个事务里要完成四件事创建订单主表记录、逐条插入明细、计算应收总额、如果是会员还要判断是否用余额支付。这个事务的难点在于明细数据来自界面层多行输入Service 方法要接收一个订单 DTO里面嵌套着明细 DTO 列表。这里 I 用 Spring 的Transactional管理事务。有个细节Transactional默认只在抛出 RuntimeException 时回滚如果业务代码用 try-catch 吞掉了异常事务就不会回滚。所以 Service 层内部不要捕获异常统一抛到外层由全局异常处理器处理。Service public class OrderService { Autowired private OrderMapper orderMapper; Autowired private OrderItemMapper orderItemMapper; Autowired private MembershipCardMapper cardMapper; Transactional(rollbackFor Exception.class) public Long createOrder(OrderCreateDTO dto) { // 1. 计算明细金额并构建明细实体 BigDecimal totalAmount BigDecimal.ZERO; ListOrderItem items new ArrayList(); for (OrderItemDTO itemDTO : dto.getItems()) { BigDecimal unitPrice itemDTO.getUnitPrice(); BigDecimal discount itemDTO.getDiscountRate(); BigDecimal itemTotal unitPrice .multiply(discount) .multiply(BigDecimal.valueOf(itemDTO.getQuantity())) .setScale(2, RoundingMode.HALF_UP); totalAmount totalAmount.add(itemTotal); OrderItem item new OrderItem(); item.setClothesTypeId(itemDTO.getClothesTypeId()); item.setClothesName(itemDTO.getClothesName()); item.setQuantity(itemDTO.getQuantity()); item.setUnitPrice(unitPrice); item.setDiscountRate(discount); item.setItemTotal(itemTotal); items.add(item); } // 2. 保存订单主表状态为已收衣 Order order new Order(); order.setCustomerId(dto.getCustomerId()); order.setStatus(OrderStatus.RECEIVED); order.setTotalAmount(totalAmount); orderMapper.insert(order); // 3. 保存明细注意明细需要拿到订单生成的自增主键 for (OrderItem item : items) { item.setOrderId(order.getId()); orderItemMapper.insert(item); } return order.getId(); } }这段代码里最容易被忽略的是RoundingMode.HALF_UP。金额计算不能默认用BigDecimal(double)构造那是坑也不能让计算结果带着多位小数落库。setScale(2, RoundingMode.HALF_UP)强制四舍五入到分且HALF_UP是银行家舍入以外的常见选择符合日常对钱的认知。折扣率从界面传入时界面上显示的是「8 折」传给后端的是0.80。事务中先插主表再插明细的顺序是必要的——明细表的外键order_id依赖主表自增主键MyBatis 的useGeneratedKeystrue配置能让insert后直接把自增 ID 写回实体对象的id字段。order.getId()拿到的不是 null而是数据库分配的真实主键。如果这里拿不到检查 Mapper XML 里是否忘了配置keyPropertyid。3.2 进度流转接口如何防止状态被重复提交状态流转的操作路径是前台选择订单 - 点击「下一状态」按钮 - 后端校验当前状态 - 更新状态并写流转记录。后端的核心逻辑是校验「当前状态是否允许跳转到目标状态」。注意这里必须是数据库中的当前状态而不是界面传过来的旧状态——两个窗口同时操作同一订单时界面显示的状态可能已经过期。Transactional(rollbackFor Exception.class) public void transitionOrder(Long orderId, OrderStatus targetStatus, Long operatorId) { // 1. 加锁读取当前状态防止并发下状态错乱 Order order orderMapper.selectByIdForUpdate(orderId); if (order null) { throw new BusinessException(订单不存在); } OrderStatus currentStatus OrderStatus.of(order.getStatusCode()); if (!currentStatus.canTransitionTo(targetStatus)) { throw new BusinessException( String.format(订单状态不可从%s流转到%s, currentStatus.getDesc(), targetStatus.getDesc())); } // 2. 执行状态变更 order.setStatusCode(targetStatus.getCode()); order.setUpdatedAt(LocalDateTime.now()); orderMapper.updateStatus(order); // 3. 写入状态流转日志 OrderStatusLog log new OrderStatusLog(); log.setOrderId(orderId); log.setFromStatus(currentStatus.getCode()); log.setToStatus(targetStatus.getCode()); log.setOperatorId(operatorId); log.setCreatedAt(LocalDateTime.now()); orderStatusLogMapper.insert(log); }selectByIdForUpdate是关键。它会在数据库层面给这一行加排他锁直到事务提交才释放。两个操作员同时点「取衣」第一个事务拿到锁并完成更新第二个事务等锁释放后拿到的是最新状态此时状态已经是「已取衣」跳转校验会直接拒绝。没有这行锁的话两个事务可能同时读到「已整烫」状态然后都认为可以跳转到「已取衣」产生两条取衣记录。状态流转日志表看起来是额外工作但对洗衣店非常实用。遇到客户投诉「我的衣服为什么一直没人洗」时查一下日志就知道每一步的操作人、操作时间是排班问题还是系统漏登记一目了然。后续要做运营分析——哪个环节耗时最长、哪个操作员效率最低——也依赖这张日志表。流转日志不用记录所有字段快照只记from_status、to_status、operator_id、created_at四个字段足够。将来要重建订单时间线按created_at排序就能还原整个过程。3.3 取衣结算余额支付与找零计算的一次性完成取衣是订单的终态操作这个接口的复杂之处在于支付方式可能是混合的——会员余额付一部分、现金付剩下的。同时还要处理「逾期保管费已经生成」的情况。取衣事务包含锁定订单、校验状态为已整烫、计算应付总金额含保管费、扣会员卡余额、记录支付流水、更新订单状态为已取衣。会员卡扣款必须做余额充足校验和一个原子扣减我把这个动作封装成一条 SQL 而不是 Java 里「select - set - update」。因为后者的并发问题在 3.2 已经出现过一次这里直接写条件更新更稳。Transactional(rollbackFor Exception.class) public void pickupOrder(Long orderId, BigDecimal cashAmount, BigDecimal cardAmount, Long operatorId) { Order order orderMapper.selectByIdForUpdate(orderId); if (order null || !order.getStatusCode().equals(OrderStatus.IRONED.getCode())) { throw new BusinessException(订单不在待取衣状态); } BigDecimal overdueFee BigDecimal.ZERO; if (order.getOverdueDays() 0) { overdueFee calculateOverdueFee(order); } BigDecimal payable order.getTotalAmount().add(overdueFee); // 校验支付总额一致 BigDecimal paid cashAmount.add(cardAmount); if (paid.compareTo(payable) ! 0) { throw new BusinessException(实收金额与应收金额不一致); } // 余额支付部分原子扣减 if (cardAmount.compareTo(BigDecimal.ZERO) 0) { int rows cardMapper.deductBalance(order.getCustomerId(), cardAmount); if (rows 0) { throw new BusinessException(会员卡余额不足); } // 插入会员卡流水 cardFlowMapper.insert(new CardFlow(order.getCustomerId(), 消费, cardAmount.negate(), orderId)); } // 记录支付流水、更新订单状态 if (cashAmount.compareTo(BigDecimal.ZERO) 0) { paymentMapper.insert(new Payment(orderId, 现金, cashAmount, operatorId)); } order.setStatusCode(OrderStatus.PICKED_UP.getCode()); order.setActualAmount(payable); orderMapper.updateStatus(order); }cardMapper.deductBalance的 SQL 是条件更新的典型范式。UPDATE membership_card SET balance balance - #{amount} WHERE customer_id #{customerId} AND balance #{amount}。这个语句有两个作用一是把「读取余额 - 判断充足 - 扣减」三步合并为一步天然防并发二是返回值rows为 0 时说明余额不足直接抛业务异常。这里有个细节cashAmount与cardAmount是由界面传入的理论上可以伪造。正规做法是后端自己查订单总额、查会员卡余额再决定怎么组合支付而不是信任前端算好的数字。但很多课设级别的系统把前端数据直接拿过来用导致「实收金额与应收金额不一致」的校验形同虚设。实际做的时候可以把支付金额的拆分逻辑也放后端前端只传「客户愿意用多少余额支付」后端校验是否小于订单余额和应付金额。3.4 查询页面的典型实现多条件组合查询与分页洗衣店前台经常需要按手机号、订单号、时间段找订单。「今天这个客户送来三件衣服取走一件剩下两件在哪」——这种查询要用多条件动态拼接 SQL。MyBatis 的if标签是最常用的方案注意避免一个常见反模式只在 Service 层判断参数是否为 null然后拼不同 SQL——那会让 Mapper XML 里的 SQL 重复且难维护。select idselectPage resultTypecom.laundry.entity.Order SELECT id, order_no, customer_id, status_code, total_amount, created_at, updated_at FROM order where if testorderNo ! null and orderNo ! AND order_no #{orderNo} /if if testcustomerId ! null AND customer_id #{customerId} /if if teststatusCode ! null AND status_code #{statusCode} /if if teststartTime ! null AND created_at gt; #{startTime} /if if testendTime ! null AND created_at lt; #{endTime} /if /where ORDER BY id DESC LIMIT #{offset}, #{pageSize} /select注意三个细节。第一where标签会自动去掉第一个AND不用手写WHERE 11——后者虽然也能跑但不够干净。第二gt;和lt;是 XML 里的转义写法直接写会让 XML 解析报错这是每个 MyBatis 新手都会踩的坑。第三分页用LIMIT #{offset}, #{pageSize}offset (page - 1) * pageSize在 Service 层算好传入或用 PageHelper 插件自动生成。如果数据量超过几万行这个查询仍然够用单店洗衣订单一年大概几千到一万单MySQL 毫无压力。时间范围查询要注意索引使用。created_at上建立索引后BETWEEN和/都能命中。但如果把created_at包在函数里比如DATE(created_at) #{date}索引就失效了全表扫描在数据量大时会有明显卡顿。所以范围查询用两个参数传时间点而不是传日期字符串。4. 数据一致性设计会员卡扣款和库存扣减为什么不能信 Java 代码4.1 单机事务能解决什么不能解决什么洗衣店管理系统是典型的单机数据库应用不需要分布式事务、不需要消息队列。Java 服务连一个 MySQL 实例所有的写操作在同一个数据库连接里完成Transactional保证原子性。但「同一个事务」有个前提Service 方法里的所有 Mapper 调用必须拿到同一个 Connection这由 Spring 的事务管理器控制。如果代码里自己new SqlSession或手动去开了一个新连接那个连接不参与当前事务就会出现「主表提交成功、明细表没写进去」的恐怖事故。单机事务能保证的是事务内的所有 SQL 要么全部成功、要么全部回滚。它不能解决的是跨请求的并发问题。比如两个操作员同时给同一个客户取衣第一个事务扣了卡余额第二个事务也扣了卡余额——如果没有锁两次都将结果写回去最后卡余额变成负数。解决办法是第四章开头说的悲观锁select ... for update或者对余额扣减用条件更新 SQL。这里要强调一个常见的误用很多人会给Transactional加propagation Propagation.REQUIRES_NEW以为这样更安全。实际上每次新开事务都意味着独立提交如果一个业务方法内嵌的另一个方法走 REQUIRES_NEW内层方法提交后外层方法抛异常回滚内层已经提交的就不会回滚。这会导致数据不一致。洗衣店系统里全部用默认的 REQUIRED 传播行为就够了。4.2 乐观锁 vs 悲观锁小门店该选哪种两种并发控制策略各有适用场景。洗衣店的实际并发量很低——一台收银机两个店员同时操作同一订单的概率极小。在这种场景下悲观锁简单直接缺点是锁等待会让界面偶发卡顿但误操作带来的数据错误远比几百毫秒延迟更严重。我的选择是订单状态流转用悲观锁会员卡余额扣减用条件更新库存数变更用乐观锁。库存场景在洗衣店主要出现在「洗涤用品」管理上——洗衣液、包装袋、衣架这些耗材。每次入库、领用都涉及库存数量的增减。不同操作员同时领用同一种耗材的概率虽然低但一旦出现丢失更新的后果是月底盘点对不上。乐观锁给库存表加一个version字段更新时带上版本号影响行数为 0 则重试。public void deductStock(Long stockId, Integer quantity) { boolean success false; int retryCount 0; while (!success retryCount 3) { int rows stockMapper.deductWithVersion(stockId, quantity, currentVersion); if (rows 1) { success true; } else { // 重查最新版本号后重试 currentVersion stockMapper.selectVersion(stockId); retryCount; } } if (!success) { throw new BusinessException(库存更新失败请重试); } }对应的 SQL 是UPDATE stock SET quantity quantity - #{quantity}, version version 1 WHERE id #{stockId} AND version #{currentVersion}。这里没有用select for update因为库存操作不像订单状态流转那样需要读取整行数据做业务判断只需要保证扣减不外泄。乐观锁在低并发下几乎没有性能损耗而且不会阻塞其他操作。值得多说一句的是重试逻辑不要写成无限循环。设置 3 次重试上限超过直接抛异常让操作员手工处理。还有一种情况是quantity扣成负数——库存表应该在quantity字段上加CHECK (quantity 0)约束或者在更新 SQL 里加AND quantity #{quantity}条件数据库层面兜底Java 代码只是第一道防线。4.3 定时任务与事务的边界逾期保管费计算的另类坑逾期保管费的计算逻辑是统计所有状态为「已整烫」且取衣时间超过 30 天的订单按每天 2 元生成待缴费用。这个任务每天跑一次通常安排在凌晨用 Quartz 或 SpringScheduled触发。这个任务很容易写错的地方在于重复计费。如果任务跑了两次或者第一次没跑完就中断第二天再跑会把前一天的保管费再叠加一次。解决思路是在订单表上加一个overdue_fee_calculated_date字段每次计算时记录计算到哪天避免同一订单同一时间段被重复加钱。Scheduled(cron 0 30 2 * * ?) Transactional(rollbackFor Exception.class) public void calculateOverdueFee() { LocalDate today LocalDate.now(); ListOrder pendingOrders orderMapper.selectIronedOrdersBefore( today.minusDays(30)); for (Order order : pendingOrders) { // 上次计算日期为空或早于今天才执行本次计算 LocalDate lastCalc order.getOverdueCalcDate(); if (lastCalc null || lastCalc.isBefore(today)) { int overdueDays (int) ChronoUnit.DAYS.between( order.getIronedTime(), today); order.setOverdueFee(BigDecimal.valueOf(overdueDays * 2)); order.setOverdueCalcDate(today); orderMapper.updateOverdueFee(order); } } }Transactional加在定时任务方法上有个隐患如果任务里处理了 500 个订单只要其中一个抛异常整个事务回滚前面 499 个的更新全部白做。这其实是一个合理的行为——要么全成功要么全失败避免部分更新让对账变得更复杂。但如果订单量很大一个事务跑几个小时会占用数据库连接太久。折中方案是把每个订单的更新拆成独立事务用编程式事务管理器逐条提交失败的单条记录日志后跳过。单店规模用整批事务即可但要把异常捕获在循环外防止一个脏数据让整个任务挂掉。Scheduled的执行频率要避开营业高峰期。凌晨 2 点半是比较好的时间门店已经结束营业数据库基本空闲。如果任务跑了超过 10 分钟要检查是不是没有给ironed_time建索引全表扫描在这种定时任务里会拖慢整个 MySQL 实例。5. 避坑指南洗衣店管理系统最常见的 5 个翻车现场5.1 衣物类型改了单价历史订单金额全变了现象运营人员把「羽绒服」的单价从 45 元改成 60 元后半个月前的历史订单明细里显示的金额也变成了 60 元月底对账怎么都对不上。原因订单明细表里的unit_price只是普通字段没有做下单时快照或者下单价是从衣物类型表里直接 JOIN 查出来的跟着类型表的修改而变动。解决下单时把单价、折扣率、衣物名称全部冗余写入order_item表。详情查询用order_item里的字段永远不要实时 JOINclothes_type取价格。后续如果发生「补差价」业务在明细表里加一个price_adjust字段做正负调整不要改原单价。5.2 客户退衣后订单消失了月末统计少一单现象客户洗到一半不要了要求取消订单前台把订单记录删掉月底统计「成交订单数」比实际接待人数少。原因把「取消」实现了 DELETE 操作。洗衣单是有价值的数据即便不产生收入也记录了客户来过、业务量有多大。删除后所有对账数据都会缺漏。解决订单状态再加一个「已取消」状态放进枚举和跳转表里取消操作也走状态流转逻辑。统计时用状态维度区分——已取衣算成交已取消算流失。SELECT时默认过滤掉已取消订单但保留数据本身。5.3 会员卡充值赠送金额被直接写进余额现象活动期间充 200 送 50前台给客户充值时在余额字段直接写了 250。月底财务发现赠送金额没做账收入和负债对不上。原因把「充值赠送」和「实际充入金额」混在一起只更新了余额字段没有记录赠送的业务含义。解决会员卡表拆成balance可用余额和bonus_balance赠送余额两个字段或者保留一个字段但必须记录充值流水流水里区分「本金」和「赠送」。扣款时约定先扣赠送余额再扣本金——这个规则要在代码里固定不能每次随心情改。5.4 取衣界面显示「已取衣」但客户说没取到现象订单状态显示 5 已取衣但客户坚持没来取监控调出来发现确实没取。原因取衣操作被重复提交了。前台操作员点了取衣按钮后界面没刷新又点了一次或者两个窗口各提交了一次。第二个请求把状态从「已整烫」改成「已取衣」又被后续操作覆盖了整烫时间。解决取衣接口必须有状态校验——只有IRONED状态才能跳转到PICKED_UP这个校验不能写在界面上必须写在后端事务里。再配合select for update锁住订单行重复提交的第二个请求会拿到更新后的状态并直接报错而不是继续覆盖。5.5 系统启动时数据库连接失败全部功能不可用现象门店电脑重启后Java 服务先启动了MySQL 还没起来系统报「Cannot create PoolableConnectionFactory」点击任何菜单都白屏。原因HikariCP 连接池在启动时初始化失败而服务本身没退出只是所有数据库操作都拿不到连接。加上 MySQL 服务是手动启动的店员不知道要去启动数据库。解决写一个启动脚本先检测 MySQL 端口通了再启动 Java 服务。Windows 上用批处理脚本mysqladmin ping检测数据库在线timeout循环重试最多等 30 秒Linux 上用systemctl start mysqld先确保服务在线。同时把 HikariCP 的initializationFailTimeout设成负数让连接池初始化失败后服务还能启动后续重试建立连接。6. 如何验证这套系统真的能扛住真实门店一个月的数据复盘与三张必查报表系统上线一周后不要只看「能不能跑」要开始做数据质量验证。我建议做三件事第一导出门店一个月的订单流水和纸质小票逐单核对第二跑一遍会员卡余额对账脚本确认每张卡的账实相符第三分析状态流转耗时分布找出流程瓶颈。第一张必查报表是各环节平均耗时。从状态流转日志表里按from_status分组计算每两个状态之间的平均耗时。「洗涤中 - 已烘干」如果平均耗时 8 小时而实际设备只需要 4 小时说明操作员没有及时做状态变更登记要培训操作习惯。这张表用一条聚合 SQL 就能出不必写额外代码。SELECT from_status, to_status, COUNT(*) AS cnt, TIMESTAMPDIFF(MINUTE, MIN(created_at), MAX(created_at)) / COUNT(*) AS avg_minutes FROM order_status_log WHERE created_at DATE_SUB(NOW(), INTERVAL 30 DAY) GROUP BY from_status, to_status ORDER BY avg_minutes DESC;第二张必查报表是按衣物类型的收入排行。从order_item表按clothes_name分组汇总item_total。这张表如果发现 80% 的收入来自三种衣物洗衣店的宣传和定价策略都要往那边倾斜。第三张是会员充值消耗比也就是「期内充值总额 / 期内消费总额」。这个比值长期大于 1.5 说明客户充了钱不消费可能服务的复购出了问题比值小于 0.8 说明大家在快速消耗余额可能是计费有误或者有大量取衣未记录。进阶功能里最实用的是「一键导出 Excel 对账单」。Java 侧用 POI 库把订单列表输出成.xlsx文件列包含订单号、客户姓名、手机号、衣物明细、订单金额、实收金额、支付方式、取衣时间。这个导出功能不要用 Swing 窗口拼字符串直接生成文件放到桌面对账目录门店财务自己打开即用。POI 的SXSSFWorkbook比XSSFWorkbook内存占用小很多导出一个月几千行数据时体验差距明显。最后一个让系统真正「活起来」的细节是取衣短信提醒。衣物到达「已整烫」状态后给客户手机发一条短信用阿里云短信 API 或聚联云内容是「您的衣物已洗好请于 X 月 X 日前到店取衣逾期将收取保管费」。这个功能一个月能减少至少 30% 的逾期保管费投诉。短信发送放在状态流转事务完成之后用独立线程池异步发送不要阻塞主事务。做完这三个验证你对这套系统的掌握程度就不再是「写完了功能」而是「知道系统每天在做什么、哪里会出错、数据能不能解释业务」。这也是我做了几个 Java 管理系统后最深的体会管理系统的难点从来不是单表增删改查而是状态、金额、时间这三个维度的交叉校验。把这些边界想清楚课设答辩被问住的可能性会大幅降低真正上线也不会每天半夜被门店电话叫醒。希望帮到你。本文还有配套的精品资源点击获取
返回列表