SpringBoot+Vue+MyBatis+MySQL单体订单管理系统实战
发布时间:2026/10/2 18:28:55来源:尧图网络
前阵陪朋友把一个小洗衣店的订单管理系统从零搭起来技术选型就是标题里那套SpringBoot Vue MyBatis MySQL。没上微服务没搞分布式更没用那些听起来很酷的中间件。做完之后的感受很直接这类单体管理系统四件套就是当前效率最高、维护成本最低的组合没有之一。这篇文章不打算给你贴整段代码而是把整个项目的设计思路、表结构、状态流转、前后端落地细节和部署踩坑记录下来。适合正在做毕业设计、接私活或者想给自家小店做一套系统的同学参考。看完你至少能搞清楚订单状态为什么要用状态机而不是随手updateMyBatis的XML动态SQL在真实业务里怎么写Vue端订单页面如何跟后端接口配合得舒服以及部署上线时最容易被卡住的几个点。1. 为什么这套技术栈到今天依然最能打1.1 业务体量决定了技术选型的上限洗衣店订单系统本质上是个典型的进销存订单场景店面收衣、工厂洗涤、按预约取回再加上会员储值和基础的营销活动。并发峰值能有多少一个社区店同时下单的人可能也就几十个一天下来几百单顶天了。这种量级单体应用随便扛。我见过不少同学一上来就想用Spring Cloud、Redis、RabbitMQ之类的东西撑场面。不是说这些技术不好而是题型写错了。一台4核8G的服务器常年负载都到不了10%你引入一套微服务网关加消息队列光维护成本就把收益吃干净了。技术选型的第一原则永远是让复杂度匹配问题规模。SpringBoot MyBatis MySQL这套单体组合天然适合这种场景。每个组件各有分工松耦合又足够简单SpringBoot负责一切对象装配、接口暴露、事务管理不用自己写一堆工厂和配置Vue负责页面交互配合Element Plus之类的组件库后台管理界面的开发效率非常高MyBatis负责SQL控制复杂的多条件查询、报表类的聚合SQL都能在XML里精细打磨比JPA那种自动SQL可预测性强太多MySQL负责数据落地只要能建好索引、写好事务这种体量下性能根本不是问题。1.2 Spring Boot 3.x与MyBatis的版本兼容是2025年最容易踩的第一个坑先说版本问题。2025年你新建项目大概率会直接拉到Spring Boot 3.x要求JDK 17基线。这时候MyBatis的Spring Boot Starter必须用3.x版本比如mybatis-spring-boot-starter:3.0.3。很多教程还停留在2.3.x的老版本那个只适配Spring Boot 2.x。你要是直接在3.x项目里引旧的starter启动时大概率会报一堆ClassNotFoundException指向org.mybatis.spring.SqlSessionFactoryBean其实问题全在starter版本不匹配。提示可以用mybatis-plusMyBatis的增强包省掉大量单表CRUD代码但如果你希望把SQL控制权牢牢握在自己手里纯MyBatis反而更适合做表结构讲解。本篇按纯MyBatis XML的路线展开便于理解底层逻辑。1.3 这套方案适合谁不适合谁适合的人很清楚第一类就是在做毕业设计的学生需要快速出成果、答辩时能把技术点讲清楚第二类是想接外包单子的开发这类小程序级别的管理系统需求量一直不少第三类是店铺老板想低成本上线找个外包或者自助搭建这套组合的运维门槛最低。不适合的人搞大型连锁洗衣品牌门店多、库存物料复杂度高、需要实时同步洗涤工厂进度的场景就得考虑微服务拆分了。不过说实话那属于另一个量级的项目不是本篇讨论范围。2. 先画清楚订单状态机代码才不会写成一团乱麻2.1 一张订单从进店到取衣的完整生命周期洗衣订单和电商订单不一样没有支付-发货那么清晰的路径。实际业务里衣服从进门到洗完取走通常要经历这么几个状态待收衣 - 洗涤中 - 洗涤完成 - 待取衣 - 已完成中间还穿插两个特殊状态洗涤失败比如衣服洗坏了、染色了、顾客要求重洗或赔偿和已取消顾客付了定金又反悔或者长时间不取作废。我第一次做这个模块时直接在订单表里放了个status字段前端凡是能点按钮的地方都能改它。结果上线没几天就出问题了衣服还在洗涤中前台小姑娘就能误操作把状态改成已完成更麻烦的是有人为了补录把状态倒着改导致报表里的数据前后矛盾。后来老老实实把状态机画出来所有状态变更收敛到一张流转表里总结下来核心规则只有几条待收衣只能流向洗涤中、已取消洗涤中只能流向洗涤完成、洗涤失败洗涤完成只能流向待取衣待取衣只能流向已完成顾客扫码取件洗涤失败只能流向待取衣重洗后正常流转或已取消赔偿退款。你可以在项目里用一个OrderStateMachine类维护一张二维流转表代码结构类似这样private static final MapInteger, SetInteger TRANSITIONS new HashMap(); static { TRANSITIONS.put(STATUS_PENDING, Set.of(STATUS_WASHING, STATUS_CANCELED)); TRANSITIONS.put(STATUS_WASHING, Set.of(STATUS_WASHED, STATUS_FAILED)); TRANSITIONS.put(STATUS_WASHED, Set.of(STATUS_READY)); TRANSITIONS.put(STATUS_READY, Set.of(STATUS_DONE)); TRANSITIONS.put(STATUS_FAILED, Set.of(STATUS_READY, STATUS_CANCELED)); } public static void validate(int from, int to) { if (!TRANSITIONS.getOrDefault(from, Set.of()).contains(to)) { throw new IllegalStateException(非法状态流转: from - to); } }所有更新状态的入口都先走validate非法流转直接抛异常绝不让脏数据落库。这一步设计可能只花了半天但后面至少帮你省了十次报表数据对不上号的加班。2.2 洗衣价格模型按件计费与按重量计费的取舍洗衣店计费模型是另一个容易被忽视的坑。日韩系和国内部分门店习惯按件计费每个品类衬衫、羽绒服、皮鞋有固定单价但也有不少店是按重量计费尤其洗大批量衣物时按公斤收费。如果想兼顾建议商品表物品类型表 订单明细表里固化两个字段price_type按件/按重和unit_price单价快照。这里有个很重要的经验价格必须快照到订单明细上不要实时关联价目表。否则两个月后你调价了历史订单的统计金额、利润报表全部错乱。项目里我习惯在order_item表里直接存item_name、unit_price、quantity、weight、amount需要算历史数据时直接按订单快照算不回溯价目表。2.3 会员储值和订单结算怎么联动不串账洗衣店的会员几乎都带储值功能。一套系统的资金流主要在这几个表里转member会员主档、member_account储值账户、account_flow余额流水、recharge_order充值记录。关键原则就一句话金额变更和流水记录必须在一个事务里。举个例子会员用余额支付订单Transactional public void payByBalance(Long orderId, Long memberId, BigDecimal amount) { // 1. 扣减账户余额 int rows accountMapper.deductBalance(memberId, amount); if (rows 0) { throw new RuntimeException(余额不足); } // 2. 记录余额变动流水 accountFlowMapper.insert(new AccountFlow(memberId, -amount, 订单支付, orderId)); // 3. 更新订单状态为待洗涤 orderMapper.updateStatus(orderId, STATUS_WASHING); }第一步扣减行数如果不是1说明余额不足直接抛异常回滚整个事务不会出现“钱扣了但订单没变成洗涤中”的尴尬。开始觉得这种流水表很麻烦但它恰恰是出问题后对账最快的工具。3. 数据库表结构设计与索引策略直接影响项目成败3.1 核心表订单主表、订单明细表整个系统大概十来张表但我建议你优先设计且设计扎实的是下面这几张。先看订单主表的核心字段简化版CREATE TABLE t_order ( id bigint NOT NULL AUTO_INCREMENT, order_no varchar(32) NOT NULL COMMENT 订单号业务查询时用, member_id bigint DEFAULT NULL COMMENT 会员ID非会员可为空, store_id bigint NOT NULL COMMENT 门店ID预留多店扩展, status tinyint NOT NULL COMMENT 订单状态10待收衣 20洗涤中 30洗涤完成 40待取衣 50已完成 60已取消 61洗涤失败, total_amount decimal(10,2) NOT NULL COMMENT 订单总额快照, discount_amount decimal(10,2) DEFAULT 0.00 COMMENT 优惠金额快照, pay_amount decimal(10,2) NOT NULL COMMENT 实付金额, pay_type tinyint DEFAULT 0 COMMENT 支付方式1余额 2微信 3支付宝 4现金, pickup_code varchar(6) DEFAULT NULL COMMENT 取衣码4-6位数字, expect_pickup_time datetime DEFAULT NULL COMMENT 预计取衣时间, remark varchar(255) DEFAULT NULL, create_by bigint DEFAULT NULL 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_status_time (status, create_time), KEY idx_member (member_id) ) ENGINEInnoDB COMMENT订单主表;几个字段设计都踩过坑给你说明白order_no不要用自增ID直接暴露给用户既容易猜又有隐私情绪。用时间戳门店编号随机数生成就行比如20250101120001这种18位格式写个OrderNoGenerator的util类专门生成。status用tinyint别用字符串。用数字存状态至少有好处状态流转时数值可直接比较传输效率也高。状态含义的映射关系放在后端枚举类OrderStatusEnum里统一管理只放int不放描述保证一处定义、多处引用。pickup_code取衣码很关键。真实场景是顾客来取衣服报手机后四位或者专门生成的取衣码店员根据码查订单。给这个字段加索引甚至可以建唯一索引能大幅提升取件时的查询速度。状态和时间字段建的组合索引idx_status_time用于门店收银台按状态时间筛选列表这是全系统访问最频繁的查询路径。3.2 订单明细表一个订单为什么必须有多个子项衣服是分批收的一个顾客可能一次拿来五件衣服三件要干洗、两件要水洗明细表设计成这个结构CREATE TABLE t_order_item ( id bigint NOT NULL AUTO_INCREMENT, order_id bigint NOT NULL COMMENT 主表ID, item_type_id bigint NOT NULL COMMENT 物品种类ID关联价目表, item_name varchar(50) NOT NULL COMMENT 种类名称快照比如 羽绒服/羊毛衫, unit_price decimal(10,2) NOT NULL COMMENT 单价快照, price_type tinyint NOT NULL COMMENT 1按件 2按重量, quantity int DEFAULT 1, weight decimal(10,2) DEFAULT 0.00 COMMENT 重量price_type2时使用, amount decimal(10,2) NOT NULL COMMENT 小计金额, remark varchar(200) DEFAULT NULL COMMENT 备注比如 袖口有污渍重点处理, PRIMARY KEY (id), KEY idx_order_id (order_id) ) ENGINEInnoDB COMMENT订单明细表;注意每条明细都冗余了item_name和unit_price这种做法叫“历史快照”。价目表后面改了价格或名称历史订单打印小票时依然显示当时的价格所有统计核算都对得上不含糊。3.3 索引设计少建没问题乱建要出事很多初学者喜欢把所有字段都加上索引或者干脆建一堆联合索引。实际上索引不是越多越好每次写入都要维护索引写性能会下降。洗衣店系统250以内的字段列表加索引的原则我个人总结成三条高频等值查询字段加普通索引member_id、store_id、order_no高频范围查询字段建联合索引(status, create_time)既支持状态过滤也支持按时间范围统计冗余和重复索引一定要清理比如你建了idx_status又建idx_status_time那前者就是冗余的直接删掉。报表SQL里经常要按天汇总订单金额建议再给create_time建个单独的索引。因为洗衣店的统计分析基本都落在create_time上。4. MyBatis落地细节动态SQL怎么写才不翻车4.1 Mapper接口与XML的边界划分MyBatis最有价值的地方在于把SQL写在哪里、怎么组织边界非常清晰。我的习惯是这么划分单表的、极简单的查询按ID查、按外键列删除直接用注解或者Mapper自带方法多表关联、动态条件、批量更新的SQL一律写XML例如复杂查询。比如订单列表页的筛选查询条件可能包括会员手机号、状态、时间范围、是否只查未取衣。如果全用Java代码拼SQL拼接出错不说SQL注入的风险也极高。XML的whereif标签天生就是干这个的select idselectOrderPage resultTypemap SELECT o.id, o.order_no, o.status, o.total_amount, o.pay_amount, o.pickup_code, o.create_time, m.name AS member_name, m.phone FROM t_order o LEFT JOIN t_member m ON o.member_id m.id where if testphone ! null and phone ! AND m.phone #{phone} /if if teststatus ! null AND o.status #{status} /if if teststartTime ! null AND o.create_time gt; #{startTime} /if if testendTime ! null AND o.create_time lt; #{endTime} /if /where ORDER BY o.create_time DESC /select4.2 永远要注意的符号转义上面代码里出现了gt;和lt;这是XML里对和的转义。新手十有八九在这里栽跟头在XML里直接写启动时项目会报XML解析错误。这个坑我当年也踩过后来养成了习惯凡是动态SQL里涉及到比较符号一律写成实体转义不要写原生符号。4.3 批量操作的性能优化别用循环单条Update洗衣店系统里有个高频操作批量修改订单状态。比如“一键通知所有待取衣顾客取衣”或者“清点衣物完成后批量入库”。如果用Java for循环一条一条update50条订单就得50次数据库往返慢且容易超时。MyBatis批量更新的正确姿势update idbatchUpdateStatusByIds UPDATE t_order SET status #{status}, update_time NOW() WHERE id IN foreach collectionids itemid open( separator, close) #{id} /foreach /update这里有个隐含问题一个批量update对应一张表可以用foreach实现如果想一次更新不同订单不同状态那得考虑分批组或case when但那样SQL会变得很长。对于洗衣店这个系统批量更新场景都集中在同状态更新上这个写法足够用了。4.4 Transactional失效的三种经典场景这不是洗衣店独有的问题但确实这个项目里最容易出现的Bug场景我直接给你排雷同类内部调用PayService的payByBalance()调用了自己的updateOrderStatus()而updateOrderStatus()上有Transactional注解。由于是内部方法调用、没有通过代理对象Spring不能拦截事务注解直接失效。解决办法是把两个方法拆到不同Service或者通过AOP代理调用。异常被吞掉方法里catch了Exception导致事务不会触发回滚。只要不是特别原因异常一律向上抛由全局异常处理器统一处理成Rest接口返回。数据库引擎不是InnoDBMySQL 5.x时代默认引擎可能是MyISAM不支持事务。项目里建表一定要显式声明ENGINEInnoDBMySQL 8默认就是InnoDB倒不用太担心。5. Vue端页面组织与前后端接口配合5.1 Vue 3 Element Plus Pinia的项目骨架2025年了Vue端直接用 Vue 3 Vite Element Plus Pinia别再用Vue 2和Vuex了。Vite的启动速度比Webpack快一个量级Element Plus 把后台管理常用组件几乎全包了表格、表单、Dialog、日期选择器、消息提示写起来非常顺。项目结构可以这样分src/ |-- api/ # axios请求封装按模块拆文件 |-- views/ | |-- order/ | | |-- OrderList.vue | | |-- OrderCreate.vue | | -- OrderDetail.vue | -- member/ |-- components/ # 公共组件比如状态标签组件 |-- store/ # Pinia状态管理 |-- router/ -- utils/5.2 订单列表页状态标签与操作按钮的联动订单列表页是整个系统的核心屏。我的做法是封装一个通用组件OrderStatusTag.vue根据status数字统一渲染成带不同颜色的Tag防止每个页面各写一套颜色映射状态标签颜色操作按钮待收衣橙色登记洗涤 / 取消洗涤中蓝色标记完成 / 标记失败洗涤完成紫色确认可取待取衣绿色完成取衣已完成默认灰查看详情已取消默认灰删除前端只把状态值传给后端后端校验完状态迁移后在响应里返回最新的状态。前端收到最新状态后更新当前行数据。这个模式下前后端配合很清爽不会出现“按钮都能点、点了报错”的现象。5.3 取衣码与扫码交互取衣场景建议直接PC端键盘输入4位取衣码或扫码枪录入。PC端实现起来很简单加一个输入框回车触发查询接口查询pickup_code匹配且状态为待取衣(40)的订单弹窗展示衣物明细和应收金额点击确认完成取衣流程。如果扫码枪是USB模拟键盘输入的实际上触发的是键盘事件。输入框加autofocus扫完码自动跳转查询。扫码枪输入极快有些浏览器输入框认不全需要在keydown事件里做防抖处理或者用延迟组装字符串这个细节不处理的话扫码时经常漏数字、少一位。我后来就是这么解决的监听回车键回车时一次性读取整个输入框的值而不是逐字符处理。5.4 axios封装统一错误处理和加载态前端另一个核心细节是axios封装。我在api/request.js里做三件事请求头注入Token、响应拦截器统一判断code字段、错误时弹出统一elMessage提示。这样业务代码里就不需要每个接口都写try-catch了。import axios from axios import { ElMessage } from element-plus const service axios.create({ baseURL: /api, timeout: 15000 }) service.interceptors.response.use( response { const res response.data if (res.code ! 200) { ElMessage.error(res.msg || 请求失败) return Promise.reject(new Error(res.msg)) } return res }, error { ElMessage.error(error.response?.data?.msg || 网络异常) return Promise.reject(error) } )6. 部署上线与避坑实战记录6.1 从源码到可访问的完整部署链路开发环境一切跑通后部署又是另一场战斗。以一台CentOS 7/8服务器为例后端构建mvn clean package -DskipTests生成target/xxx.jar。建议使用Spring Boot Maven插件里的repackage打胖包里面自带Tomcatjava -jar就能跑。静态资源处理前端npm run build生成dist目录里面是纯静态HTML/JS/CSS。可以用Nginx直接托管也可以扔进Spring Boot的static目录里。Nginx反代前端请求/api时反代到后端服务避免跨域问题server { listen 80; server_name your-domain.com; root /opt/lcd/static; index index.html; location /api/ { proxy_pass http://127.0.0.1:8080; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } }这不光解决跨域还能把前后端绑在同一个域名下线上部署逻辑最简洁。6.2 MySQL 8密码认证插件最容易卡住的一关2025年用MySQL 8.0是很正常的事但MySQL 8默认的认证插件是caching_sha2_password而Navicat老版本和部分旧的JDBC驱动只支持mysql_native_password连库时会报Public Key Retrieval is not allowed或者Authentication plugin caching_sha2_password cannot be loaded。两个解决办法给用户改成旧插件ALTER USER lcd% IDENTIFIED WITH mysql_native_password BY 密码;连接JDBC串加参数jdbc:mysql://localhost:3306/lcd?allowPublicKeyRetrievaltrueuseSSLfalse建议直接在你的 application.yml 里加上allowPublicKeyRetrievaltrue和useSSLfalse本地开发和服务器跑着都省心不用每次新环境都改用户认证方式。6.3 内存和启动参数小服务器也能跑稳洗衣店项目的并发量很小1核2G的轻量服务器完全能跑。但需要注意的是Spring Boot 3默认的JVM参数可能让它启动就吃掉几百兆内存加几个参数控制一下java -Xms256m -Xmx512m -jar lcd-server.jar --spring.profiles.activeprod-Xms和-Xmx分别控制初始堆和最大堆。设成256m/512m对这个小项目足够服务器内存不至于被占满。把服务做成systemd守护进程是另一件值得做的事[Unit] DescriptionLaundry Order System Afternetwork.target [Service] Userroot ExecStart/usr/bin/java -Xms256m -Xmx512m -jar /opt/lcd/lcd-server.jar ExecStop/bin/kill -s QUIT $MAINPID Restartalways [Install] WantedBymulti-user.target这样就算服务崩了或者服务器重启系统都会自动拉起Java进程比nohup手动挂后端稳太多。6.4 上线前的最后一道检查清单部署完成不等于能交付。建议按以下清单过一遍每一个都曾经是真实事故时间格式是否统一后端LocalDateTime和前端格式化方式是否一致跨时区问题在本地可能测不出来金额字段是否全部用BigDecimal/ Decimal杜绝double在计算上出精度问题打印小票是否传了正确的store_id多店扩展后别打错店名定时任务有没有关闭开发环境的开关比如自动提醒取衣的定时任务不能再开发环境也满世界发短信数据库定期备份写个crontab每天夜里mysqldump备份文件保留7天这是一条保命底线。写在最后从技术难度上说SpringBoot Vue MyBatis MySQL这套洗衣店订单管理系统并不复杂但真正做好一个能交付、能长期稳定运行的项目功夫全在那些“看起来不重要”的细节上状态机规范了数据流转价格快照保证了对账清晰索引策略撑住了查询性能事务边界堵住了资金串账部署参数决定了线上会不会OOM。我个人做完这个项目的最大感受是这类业务系统的核心不是炫技而是把业务规则用最简单、最不容易出错的代码形态固化成流程。如果你正打算做类似的管理系统项目建议不要把精力浪费在纠结要不要上微服务、要不要拆前后端工程先踏踏实实把订单状态流转、价格模型和资金流水这几根柱子立稳系统自然就扛得住业务了。要是后面项目真做大了、门店多了、并发高了再把存储、缓存、消息队列这些组件按需引入那才是平滑演进的正路。
网站建设高端定制企业官网