ARTICLE DETAIL

资讯详情

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

SpringBoot+Vue实战:洗衣店订单管理系统全栈开发与设计详解

SpringBoot+Vue实战:洗衣店订单管理系统全栈开发与设计详解 洗衣店订单管理系统这个题目乍一看挺“小”但真正动手做的时候你才会发现一个小型订单系统该有的东西它几乎全占了客户档案、服务与定价、下单、订单状态流转、结算统计、权限区分……把它完整做出来SpringBoot、Vue、MySQL、MyBatis这套后端框架里最常用的一班子技术算是扎扎实实过了一遍。这篇文章我把整个系统的设计思路、数据库结构、后端核心代码、前端页面逻辑、以及我在实际开发中踩过的坑都写出来因为这套系统本身就是从真实业务里抽象出来的适合正在做Java全栈项目、毕业设计的人对照参考也适合想快速搭一套门店管理系统的朋友抄作业。1. 项目背景与整体方案设计1.1 洗衣店靠什么赚钱订单系统到底管哪些事洗衣店的业务链条并不复杂顾客送衣服来前台登记衣物信息确认洗涤服务项目和价格衣服进入清洗、烘干、熨烫环节最后通知顾客取衣并结账。问题往往出在“登记”这个动作太原始。我之前调研过几家洗衣门店很多还是手写单或者Excel表格顾客报个手机号店员翻半天聊天记录找上次的送洗信息。到了月底对账的时候几张纸条上的金额加起来对不上漏单、记错价格是常有的事。所以这套订单管理系统的核心价值就体现在三个点上。第一把顾客信息沉淀下来送洗几次、常洗什么衣物、偏好什么服务都在系统里能查到。第二把每一笔订单从“接收衣物”到“顾客取走”的完整状态管住衣服在哪里、是谁经手的打开后台一目了然。第三把每天的营业额、订单数、服务项目分布统计出来店主不用再拿计算器一下午。系统角色也按真实门店来分店员负责开单、改状态、收银店长或者老板可以查看统计报表和管理基础数据。不需要搞太复杂的权限模型一张用户表带一个角色字段就能覆盖这些需求。这也是我在设计初期反复提醒自己的不要为了显得高级把不存在的需求也做进去。1.2 技术选型为什么是SpringBootVueMyBatis技术栈用的是目前中小型全栈项目最常见的组合每个选型都有它在这个场景下的理由。后端选SpringBoot核心就一句话省配置。不用像传统SSH那样写一堆XML配置文件内嵌Tomcat让项目可以直接跑起来开发阶段也不需要单独装服务器环境。对洗衣店这种内部管理系统的项目来说SpringBoot的自动配置和starter机制能让我们把注意力集中在业务代码上而不是浪费在环境搭建上。持久层选MyBatis是因为订单系统里有大量带条件组合的查询和跨表统计。比如“查一下最近一周所有‘清洗中’状态的会员订单按下单时间倒序”这种SQL直接用MyBatis的XML写比JPA那种自动生成的查询更直观也更容易控制性能。另外MyBatis对SQL的容忍度很高项目的开发者只要会写SQL很快就能上手。前端选Vue考虑到订单管理页面的交互强度。下单时要选会员、选衣物品类、选服务类型列表页需要实时筛选和分页统计页要展示图表。Vue的组件化开发配合Element UI组件库页面写起来效率确实高而且数据双向绑定让表单交互比传统JQuery时代舒服太多。数据库选MySQL没什么好说的开源、免费、稳定小型门店单点部署完全够用。整个系统表数量不超过十张MySQL在这种量级下性能绰绰有余。这套组合还有一个额外优势市场认知度高。不管你是做毕业设计还是以后写简历SpringBootVue这两个词在Java开发岗位的需求描述里出现频率极高做一遍等于提前演练了工作里最常见的协作模式。2. 数据库设计与核心表结构2.1 从业务单据反推数据模型设计数据库表之前我习惯先把业务单据在脑子里过一遍。洗衣店的核心单据就是“订单”顾客来送洗店员开出的那张单子上有什么顾客姓名、手机号、送洗日期、衣物名称、衣物数量、洗涤项目、单价、总金额、预计取衣日期、当前状态。顺着这张单子去拆字段主表结构就出来了。围绕订单还要延伸出两张辅助表。一张是顾客表把顾客的姓名、手机号、累计消费额度抽出来避免每次下单都让店员重复录入顾客信息订单表里只需要存一个customer_id就行。另一张是衣物与服务表严格来说可以把洗衣项目和衣物类型合并成一张字典表比如“羽绒服-干洗”、“衬衫-水洗”每一种组合对应一个价格这样前台选服务时自动带出价格月底统计各服务项目的营收也方便。整个系统的核心表我设计成六张表名作用user系统登录用户区分店员/店长customer顾客/会员基础信息orders订单主表存顾客、下单人、总金额、状态order_item订单明细表一条订单对应多件衣物service_item服务项目与价格的定义表operation_log操作日志表记录状态变更等关键行为这里有个设计细节我想强调订单表和订单明细表的主键不要直接用自增id更建议用雪花算法生成的分布式ID或者至少在代码里生成一个timestamp随机数的订单号。原因后面我会单独讲这里先记住结论——订单号对外展示时用业务单号内部关联才用主键id。2.2 订单主表与明细表为什么要分开很多新手在写订单表时会试图把顾客洗的三件衣服拼成一个字符串塞进一个字段里比如“羽绒服x1、衬衫x2”。这个设计在演示阶段看起来很省事但一旦要做“查某位顾客上个月洗过几件羽绒服”这种需求字符串字段查起来几乎没法写SQL。正确做法是拆成主表和明细表。orders表存这条订单的公共信息下单人、总金额、状态order_item表存衣物级别的信息每件衣物的名称、数量、单价。一单多明细通过order_id外键关联。这样做的好处是明细可以单独查询、单独统计、单独修改计价时每一行都有据可查。建表语句我贴出来这里只保留核心字段CREATE TABLE customer ( id BIGINT NOT NULL AUTO_INCREMENT COMMENT 主键, name VARCHAR(20) NOT NULL COMMENT 姓名, phone VARCHAR(20) NOT NULL COMMENT 手机号, level TINYINT DEFAULT 1 COMMENT 会员等级, total_amount DECIMAL(10,2) DEFAULT 0 COMMENT 累计消费, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_phone (phone) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT顾客表; CREATE TABLE orders ( id BIGINT NOT NULL AUTO_INCREMENT, order_no VARCHAR(32) NOT NULL COMMENT 业务单号, customer_id BIGINT NOT NULL, operator_id BIGINT NOT NULL COMMENT 开单店员, total_amount DECIMAL(10,2) NOT NULL DEFAULT 0, status TINYINT NOT NULL DEFAULT 0 COMMENT 0待收衣 1清洗中 2待取衣 3已完成 4已取消, remark VARCHAR(255) DEFAULT NULL, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_order_no (order_no), KEY idx_customer_id (customer_id), KEY idx_status (status) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT订单主表;注意我在创建订单表时给customer_id和status都建了索引。见过不少人忽略这一点订单量几百条的时候无所谓一旦上万条按顾客查历史订单或者按状态筛选订单时全表扫描的速度会让人等到怀疑人生。洗衣店虽然订单量不算特别大但既然SQL性能是MyBatis的核心卖点索引这件事就从设计阶段起步吧。2.3 用状态字段控制订单全流程订单混乱的根源往往不在数据存储而在状态管理。我见过有的项目用字符串直接存状态比如“待洗衣”、“已洗完”、“客户已拿走”前端页面上到处if判断这些中文字符串改个名字要牵动一堆代码。正确做法是用一个int类型的状态字段配合后端常量类或者枚举类来管理。这份系统里我把订单状态设置成五个阶段状态码含义允许的后续操作0待收衣确认收衣进入清洗中或取消1清洗中标记清洗完成进入待取衣2待取衣确认顾客取衣进入已完成3已完成订单结束仅可查询4已取消订单失效仅可查询状态码在前后端共同约定前端只渲染状态对应的文案和按钮。后端每个状态流转都写在Service层里前端不直接修改订单状态必须通过调用对应的接口来触发。这样做的好处是以后要想在“确认取衣”这个动作上增加短信通知只需要改后端一个方法前端无感。3. 后端实现从Controller到SQL3.1 分包结构与核心代码流程后端代码如果按“功能模块”分包比如order包、customer包、user包写起来倒是直观但说实话不利于后续维护。我这里采用的是现在Java后端项目里主流的“按层分包”和“按模块分包”结合的方式外层按三层架构分controller、service、mapper里面再按业务模块区分文件。结构大致如下com.laundry ├── controller │ ├── OrderController.java │ └── CustomerController.java ├── service │ ├── OrderService.java │ └── impl/OrderServiceImpl.java ├── mapper │ ├── OrderMapper.java │ └── OrderMapper.xml ├── entity │ ├── Order.java │ └── OrderItem.java ├── vo │ └── OrderVO.java └── common ├── Result.java └── StatusEnum.java入口是Controller它只负责接收前端的参数并调用Service不写业务逻辑。业务判断和事务控制都在Service层完成。Mapper只做数据库交互不掺杂业务代码。责任分清楚之后定位问题很快——页面数据不对先看ServiceSQL报错只看Mapper.xml就行。核心的下单接口我简化后大概是这个流程PostMapping(/order/create) public Result createOrder(RequestBody OrderCreateDTO dto) { Order order new Order(); order.setOrderNo(OrderNoGenerator.generate()); order.setCustomerId(dto.getCustomerId()); order.setOperatorId(dto.getOperatorId()); order.setTotalAmount(dto.getTotalAmount()); order.setStatus(StatusEnum.PENDING.getCode()); ListOrderItem items new ArrayList(); for (OrderItemDTO itemDTO : dto.getItems()) { OrderItem item new OrderItem(); item.setItemName(itemDTO.getItemName()); item.setQuantity(itemDTO.getQuantity()); item.setPrice(itemDTO.getPrice()); items.add(item); } orderService.createOrder(order, items); return Result.success(); }Controller里只做参数接收和对象组装真正落库的是这行代码Transactional(rollbackFor Exception.class) public void createOrder(Order order, ListOrderItem items) { orderMapper.insert(order); for (OrderItem item : items) { item.setOrderId(order.getId()); orderItemMapper.insert(item); } }注意createOrder方法头上的Transactional注解。这个事务注解是后端的保命符如果订单主表写入成功但明细表在插入时因为某个字段超长抛了异常没有事务的话数据库里就会残留一条“无明细”的脏订单有了事务主表和明细表要么都成功要么都回滚。这个点面试时经常考做项目时更是要养成习惯。3.2 MyBatis动态SQL与多条件查询订单管理页面最常见的操作就是列表筛选按订单号查、按顾客手机号查、按状态查、按下单日期范围查。这些条件还是组合出现的。用MyBatis的XML写动态SQL来处理这种场景是它相比JPA最舒服的地方。一个典型的订单查询Mapper如下select idqueryOrderList resultTypecom.laundry.vo.OrderVO SELECT o.id, o.order_no, c.name AS customer_name, c.phone AS customer_phone, o.total_amount, o.status, o.create_time FROM orders o LEFT JOIN customer c ON o.customer_id c.id where if testorderNo ! null and orderNo ! AND o.order_no LIKE CONCAT(%, #{orderNo}, %) /if if testphone ! null and phone ! AND c.phone LIKE CONCAT(%, #{phone}, %) /if if teststatus ! null AND o.status #{status} /if if teststartDate ! null AND o.create_time #{startDate} /if if testendDate ! null AND o.create_time lt; #{endDate} /if /where ORDER BY o.create_time DESC /select这里用到了 标签而不是在WHERE后面硬拼“11”。 标签会自动处理第一个条件前面的AND整个SQL生成出来干净不少这也是MyBatis动态SQL里最基础也最实用的一个技巧。还有两个配置层面的细节容易让刚接触MyBatis的人栽跟头。第一个是字段映射数据库的customer_name是下划线风格Java实体类里是驼峰风格customerName如果不配置mapUnderscoreToCamelCase查询结果会发现customerName为null而customer_name这个字段正常返回。在application.yml里设置一下就好了mybatis: configuration: map-underscore-to-camel-case: true第二个是日志打印。开发联调阶段一定要开启MyBatis的SQL日志否则遇到数据对不上的问题你连到底执行的SQL是什么样都不知道。配置并开启后控制台会直接打出带?占位符和参数值的SQL语句和对应的查询结果行数排查问题快很多。3.3 状态流转接口的实现思路状态流转的接口我一开始只写了字符串拼接式的一堆setter后来发现订单状态一多代码越来越乱。重新整理后我把状态变更收敛成了一个方法public void updateStatus(Long orderId, Integer targetStatus, Long operatorId) { Order order orderMapper.selectById(orderId); if (order null) { throw new BusinessException(订单不存在); } // 校验当前状态是否可以流转到目标状态 StatusTransitionValidator.validate(order.getStatus(), targetStatus); order.setStatus(targetStatus); orderMapper.updateStatus(order); // 写操作日志 OperationLog log new OperationLog(); log.setOrderId(orderId); log.setOperatorId(operatorId); log.setToStatus(targetStatus); operationLogMapper.insert(log); }StatusTransitionValidator里维护一张“状态允许流转表”用枚举或者Map存好哪个状态可以跳到哪个状态都只配置一次。这种方式比在Controller里写一堆if-else清晰得多而且以后状态增加时只改这一处校验逻辑。操作日志在这里的用处是一旦顾客投诉说衣服取错或者状态跳变后台能把整个变更链条还原出来这在真实门店场景里是非常必要的。4. 前端实现Vue页面与接口对接4.1 前端工程结构与接口封装前端我用的是Vue 2.7配合Element UI项目结构按页面功能来分。虽然Vue 3现在已经是主流但对没有太多前端经验的人来说Vue 2的选项式API写起来更直白this关键字一用到底不太需要理解Composition API那一套。做这个系统时我建议用自己最熟、最不容易出错的版本技术栈够用就好。前端目录结构如下src ├── api │ ├── order.js │ └── customer.js ├── router │ └── index.js ├── views │ ├── Login.vue │ ├── OrderList.vue │ ├── OrderCreate.vue │ └── Dashboard.vue ├── utils │ └── request.js └── App.vue重点说一下axios封装。项目里所有接口请求都要经过统一封装的request.js我在里面做了两件事请求拦截器统一携带token响应拦截器统一解析后端返回的Result结构。后端接口无论成功失败返回的JSON都是{code: 200, message: success, data: {...}}这种格式前端拦截器只需要判断code是否为200不是的话直接弹错误提示业务代码里一行try-catch都不用写整体清爽很多。下单页面是前端交互最复杂的部分核心逻辑是选顾客、添加衣物明细、每件衣物选择服务项目后自动带出单价、计算总金额、提交订单。页面上我用了Element UI的下拉选择器加可编辑表格选服务项目时通过级联数据联动价格字段金额部分由前端先算一次后端再验算一次防止有人通过篡改请求金额下单。4.2 订单列表页与状态按钮的交互设计订单列表页是整个系统被点击最多的页面。我分了三个区域顶部是搜索筛选栏、中间是状态标签栏、下面是订单表格。状态标签栏点击某个状态会把对应的status传给接口表格数据随之刷新。每次状态变更后调接口成功后刷新当前列表而不是重新拉取全部数据。状态按钮的显示规则在前端也要处理。比如一个“已完成”的订单不应该再显示“确认取衣”按钮。我是根据当前订单状态配合后端返回的可操作标识来渲染按钮的简单做法是后端在查询列表时直接返回一个nextStatus字段前端根据这个字段决定显示哪个按钮。这个设计把状态机的控制权牢牢放在后端前端只是被动渲染逻辑不容易乱。取衣确认和取消订单这种常用操作我封装成了一个公共方法传入订单id和要更改的目标状态点击时弹出确认框再调接口。特别是“取消订单”这个操作需要二次确认防止店员误触这个细节在真实门店场景里体验差别很大。4.3 前后端联调时的常见坑前后端分离开发联调阶段的技术债是绕不过去的。第一个坑是跨域。本地前端跑在8080端口后端跑在8081端口浏览器直接访问会报CORS错误。当时我的第一反应是加一个后端全局跨域配置类实现WebMvcConfigurer的addCorsMappings方法后来为了稳妥因为项目最终是单机部署我干脆让后端把前端打包后的dist目录放进去一起托管开发时用跨域配置部署时直接同源一劳永逸。第二个坑是日期格式。MySQL的datetime类型返回给前端的是类似于“2024-06-15T14:20:30”的字符串前端表格组件直接展示会很难看。我在全局axios响应拦截器里并没有统一处理因为有些模块确实需要原始时间值。对于订单列表这种展示场景我在后端VO中就直接格式化好了一个“yyyy-MM-dd HH:mm:ss”的字段前端只做展示减少了许多date-fns类的依赖处理。第三个坑是金额精度。Java的double计算金额会导致精度丢失这个讨论已经老生常谈。我的建议是数据库DECIMALJava用BigDecimal前端只负责展示两位小数不要用JavaScript的浮点数去算金额。后端接收金额参数时直接接收BigDecimal类型前端传一个字符串“128.00”都没有问题比强行用Number好很多。5. 实操过程中的问题清单与排查思路5.1 经典问题速查表我把这个项目里遇到的值得记录的问题整理成一个表格按照“现象、原因、解决思路”三列拆开。这些问题我基本都真实撞到过每次排查都花了不少时间。现象可能原因解决思路前端调用接口报404请求路径写错或者Controller没有正确扫描先看控制台请求URL再对比后端RequestMapping注解值注意类级路径与方法级路径是否拼接正确接口返回500控制台看不到SQL日志MyBatis日志级别没配置在application.yml里配置mybatis.configuration.log-impl: org.apache.ibatis.logging.stdout.StdOutImpl查询列表数据部分联表字段为null下划线字段与驼峰属性映射没开启设置map-underscore-to-camel-case为true或编写resultMap最推荐前者插入订单明细失败但订单主表已经有数据事务没生效检查Service方法是否有Transactional注解以及是否在Spring管理的Bean里传时间参数到后端日期少了8小时时区配置不一致MySQL连接串加serverTimezoneAsia/Shanghai并在JVM启动参数中设置-Duser.timezoneAsia/Shanghai前端跨域请求被拦截后端没有配置CORS或配置了但被过滤器拦截写一个CorsFilter或实现WebMvcConfigurer并在网关/过滤器链条中确认优先级分页数据总条数对但当前页数据不对分页插件使用方式错误PageHelper导入了但没在Mapper调用前PageHelper.startPage()注意分页语句必须紧跟查询方法5.2 MyBatis一对多查询的N1问题订单列表需要显示每一单包含的衣物明细最容易想到的方式是查完订单列表之后在循环里再查一次明细表。订单量少时没感觉但压力测试时就会发现SQL执行次数暴增。这个问题的专业叫法是N1查询先执行1条查主表再对每条主记录执行N条查明细。解决方式不复杂用MyBatis的collection嵌套结果映射就能一次性查出来。在XML里把订单明细查询和主查询合并通过resultMap配置一对多关系。我在订单列表页面的SQL里就这样做了一次查询把订单和明细全部查出来前端拿到后自行按订单id分组展示。resultMap idOrderWithItemsMap typecom.laundry.vo.OrderWithItemsVO id propertyid columnid/ result propertyorderNo columnorder_no/ result propertycustomerName columncustomer_name/ collection propertyitemList ofTypecom.laundry.vo.OrderItemVO id propertyid columnitem_id/ result propertyitemName columnitem_name/ result propertyquantity columnquantity/ result propertyprice columnprice/ /collection /resultMap注意collection标签里每个子元素都要有独立的别名否则多个订单的明细id会混在一行返回这是resultMap联查最容易踩的坑。写完之后务必验证一下日志中SQL执行次数确保是“1次主查询1次明细查询”而不是循环查明细性能才算达标。5.3 订单号生成与并发安全的细节订单号是门店和顾客沟通的凭据设计时要保证两点唯一性和可读性。常用方案是“前缀日期随机数”比如XD20240615 142530 12345。我用了Redis或数据库唯一索引来兜底生成订单号时通过唯一约束防止极端情况下重复。项目里如果不想引Redis直接用时间戳加随机数也基本够用但一定要在订单号字段上加唯一索引万一并发时重复数据库会直接拦住而不是静默出错。这里还提醒一个事项业务订单号和数据库主键id必须分开。id对外不可预测订单号才是给顾客看的。如果直接把自增id当作订单号展示顾客发现自己的订单号是1、2、3不仅显得系统简陋还会暴露门店的经营数据量这个细节容易被忽略但体验影响很大。6. 系统的复用与扩展方向6.1 这套系统能改造成什么样子做完整套系统后最大的感受是它的结构非常容易被复用。换一个场景把“衣物”换成“宠物洗护”、“摄影套餐”、“小型维修工单”这套订单主表加明细表、状态流转、顾客档案的骨架可以直接平移过去。实际项目中我后来接了一个宠物店的预约美容订单需求业务字段换了换核心代码改动不到三成。如果要继续扩展可以加上三个方向。第一微信公众号或小程序端让顾客自助下单并查看洗衣进度后端只需要提供对外的OpenAPI订单状态推送用微信模板消息。第二接入移动支付收银时生成收款码支付成功后自动把订单状态从“待收衣”转为“清洗中”这一步可以在支付回调里触发事务边界要设计好。第三增加库存和物料管理洗衣店的洗涤剂、包装袋消耗也可以入系统但这属于进销存范畴不建议一次性塞进来。6.2 给正在选毕业设计题目的人一点建议很多同学选毕业设计要不就是选一个到处都是同类品的“图书管理系统”要不就是选一个技术堆得很高但业务说不清楚的“基于微服务架构的XX平台”。这套洗衣店订单系统的选题价值就在于业务域足够小、足够具体但覆盖了全栈开发的核心链路演示时也有可讲的点。答辩时老师问“为什么订单和明细要分两张表”、“状态流转为什么不能前端直接改”、“金额为什么用BigDecimal”每一个都能答出实际依据这比包装一堆技术名词有用得多。另外一个实际经验做系统之前先把数据库表关系梳理清楚再动代码。我见过太多人先写实体类再改表结构最后表结构改得面目全非。先建表、填几条模拟数据、写几条测试SQL把业务跑通一遍再写后台代码整体推进速度快一大截。最后一个建议写代码时永远把“用户数据安全”放在心上。这个项目虽然只是门店内部系统但顾客的电话号码、消费记录都属于敏感信息登录密码一定要用BCrypt加密存储不要用MD5。多花十分钟把基础的防护做了无论是交给客户验收还是放进自己作品集都更有底气。我实际开发这套系统的过程中最深的体会是全栈项目的难度并不在某个单一技术上而是如何把前端交互、后端接口、数据库表结构三者对齐到同一套业务语言上。订单状态改一个数字前端按钮就会变报表数据也会变这是整个系统最紧凑也最有趣的部分。哪怕你做的不是洗衣店也建议把“状态流转”和“主表明细拆分”这两个设计点想透市面上大部分管理系统的核心都在这里。这套代码我后续还会根据实际使用反馈再打磨过程中发现的更多细节仍然值得继续分享。
返回列表