洗衣店这种业态看着简单实际管起来全是细节。今天洗了几件、明天谁来取、哪件衣服加了急、哪件染色需要特殊处理全靠老板脑子记或者翻一个破本子到了旺季根本顶不住。这个“基于SpringBootVue的洗衣店订单管理系统”解决的问题就是这个——把接单、洗衣流程、取衣结算整个链路搬到线上让前台、洗衣工、老板各看各的订单状态清清楚楚账目不再靠猜。我也借着这套系统的设计与实现把Java技术栈里SpringBoot、MyBatis、MySQL和前端Vue全家桶从搭建到部署的完整套路捋一遍。无论你是准备拿它当毕业设计还是想给自家小店做一套真实可用的管理工具这篇文章都能给你一条能直接落地的路径。先说说这套系统的整体形态。后端是SpringBootMyBatisMySQL前端是Vue加Element UI这类组件库前后端通过RESTful接口通信。订单从顾客送衣进店开始录入系统后生成唯一的订单编号后续的洗涤、熨烫、质检、取衣、结算全部围绕这个编号走状态流转。权限上分管理员和普通操作员两个角色管理员能看统计报表、管员工账号操作员负责日常接单和状态更新。数据库设计上围绕订单主表做文章再挂上订单明细表、服务项目表、客户表、用户表这样既满足业务查询也方便后续扩展会员积分、套餐这些玩法。为什么这套技术搭配在管理类系统里这么常见说白了就是“稳”和“快”。SpringBoot把Spring那一堆繁琐的配置全自动了一个注解搞定一个Bean开发效率直线上升MyBatis半自动的SQL映射让你在复杂查询时能直接写SQL控制执行计划不像JPA在某些多表关联场景下生成一堆莫名其妙的慢查询MySQL作为关系型数据库的万金油部署简单、生态成熟对洗衣店这种数据量级的中小业务完全够用Vue则是当前前后端分离时代最好上手的前端框架组件化开发让页面复用度很高配Element UI这种现成组件库后台管理界面几天就能搭个七七八八。这套系统适合谁第一类正在做毕业设计或课程设计的计算机专业学生这个选题业务清晰、技术栈主流、工作量适中从数据库设计到前后端联调都是标准流程拿来应付答辩绰绰有余简历上也好写。第二类真正经营洗衣店、干洗店的小老板如果店里还在用纸质单据计账这套系统可以让前台扫码查单、老板手机看营收报表每天省下来的对账时间就是实打实的利润。第三类刚入行的Java开发或前端新人想找个完整项目练手看明白订单状态机怎么设计、事务怎么控制、跨域怎么处理比刷一百道面试题都管用。1. 技术选型与整体方案设计1.1 为什么是SpringBootMyBatisMySQL做管理系统技术选型的第一步不是追新而是想清楚团队的维护成本和项目的真实规模。我见过不少项目一上来就用微服务架构、分布式事务折腾半天连个订单表都没建好这就是典型的技术过度。洗衣店订单系统本质上就是一个单体的CRUD加强版核心是订单流的增删改查和状态更新用SpringBoot单体应用完全能扛住开发效率比微服务高一个量级。SpringBoot在这个系统里的角色是装配工。你只需要在pom.xml里引入spring-boot-starter-web、spring-boot-starter-jdbc这些起步依赖它自动帮你把内嵌Tomcat、DispatcherServlet、数据源、事务管理器全部配好。开发时不用写一堆XML配置文件一个application.yml搞定数据源、端口、日志等级。加上Spring Boot Actuator还能提供健康检查接口部署到服务器上出了问题能快速定位。MyBatis值得单独说道。很多初学者纠结MyBatis和MyBatis-Plus选哪个我的建议是毕设或教学项目从原生MyBatis入手更好。为什么因为原生MyBatis能让你真正理解SQL映射、参数传递、结果集映射这些底层机制。你写一个insert语句要自己考虑useGeneratedKeys拿自增主键你写一个多表联查要自己处理resultMap的association和collection。这些踩坑的过程才是面试时你能讲出东西的来源。当然如果追求开发速度MyBatis-Plus的单表CRUD确实省事但遇到复杂报表查询最后还是得回归手写SQL。MySQL 5.7或8.0都可以这个项目用5.7也行8.0也行主要看服务器上装的是哪个版本。要注意的是驱动版本要和数据库版本匹配MySQL 8.0的驱动类名改成了com.mysql.cj.jdbc.DriverURL里还要加上serverTimezoneAsia/Shanghai否则时间字段会差8个小时。这个问题后面常见问题部分我会专门讲。1.2 功能模块与业务流程拆解洗衣店订单管理系统的核心业务就是围绕一件衣服从进店到出店的完整生命周期。我画不出流程图但可以口述清楚这个流程顾客到店送衣 → 前台检查衣物状况、确认洗涤方式 → 录入系统生成订单状态为待洗涤 → 洗衣工接收订单开始洗涤状态变为洗涤中 → 洗涤完成进入质检状态变为质检中 → 质检通过衣物入库等待取件状态变为待取衣 → 顾客到店取衣核对订单号结账订单完成状态变为已完成。如果中途顾客加洗一件或者有衣物需要特殊处理就在订单明细里追加记录。围绕这个流程系统的功能模块可以拆成五大块第一个是订单管理模块这是整个系统的心脏。包含订单的创建、查询、状态流转和明细管理。创建订单时要支持选择服务项目普通水洗、干洗、熨烫、加急等自动计算金额还要记录顾客信息。查询订单要支持按订单号、手机号、状态、日期范围多个维度筛选方便前台快速找到订单。第二个是客户管理模块。洗衣店的客户复购率很高所以客户信息要独立于订单存储一个客户可以关联多个订单。客户表里除了姓名、手机号这些基础信息我建议加上累计消费金额和会员等级字段方便以后搞充值赠送、折扣营销。这次的设计里就把客户表和订单表做了一对多关联订单表通过外键customer_id关联客户。第三个是服务项目管理模块。不同类型衣物的洗涤价格不同服务项目表用来维护这些定价。比如普通外套水洗20元羽绒服干洗50元加急服务加收50%。这个表设计成可配置的老板想调价的时候不用改代码直接在管理后台改数据库就行。第四个是员工管理模块。对应系统用户表字段包括用户名、密码、姓名、角色。角色就分管理员和操作员两种管理员拥有全部权限操作员只能操作订单相关功能。密码用BCrypt加密存储登录成功后签发Token维持会话。第五个是统计报表模块。这是老板最关心的一块。按日、周、月统计订单数量和营收总额能按服务项目查看销量占比。SQL实现上用GROUP BY加DATE_FORMAT函数就能搞定数据展示用Vue加ECharts画折线图和柱状图一眼就能看清经营趋势。1.3 数据库表结构设计与关系梳理数据库设计是管理系统的地基表结构建歪了后面的代码怎么写都别扭。我把关键表列出来你可以直接照着建库。订单主表order_info这是核心表。字段设计上除了常规的id、order_no、customer_id、status我特意加了create_time、update_time、finish_time这三个时间字段。为什么单独加一个finish_time因为统计报表里要算平均订单完成时长只有订单创建时间和完成时间两个节点是不够的finish_time能在订单状态变更为已完成的那一刻自动记录查报表时直接减create_time就是完成时长。状态字段用tinyint存数字枚举0待洗涤、1洗涤中、2质检中、3待取衣、4已完成、5已取消比直接用字符串省空间且查询高效。订单明细表order_item记录一个订单下的多件衣物。因为顾客可能一次送三件衣服每件衣服的洗涤方式不一样价格也不同所以订单和明细必须是一对多的关系。字段包括id、order_id、item_name衣物名称、service_type洗涤方式、price、note备注比如是否需要去渍、是否易褪色。这个表建好后统计哪种服务项目卖得好的时候直接GROUP BY service_type就能出数。客户表customer字段有id、name、phone、address、total_amount、level。total_amount存累计消费金额每次订单完成时用事务更新这个字段。level字段是冗余设计我用它做会员分级比如消费满500是银卡会员、满1000是金卡会员前台结账时根据level打折扣。这个逻辑放前端判断还是后端判断必须是后端。前端拿到的只是展示数据计算折扣和金额这种核心业务逻辑在后端Service层做前端传过来的价格参数一律不信任。员工表sys_userid、username、password、real_name、role。username加唯一索引防重复。角色用字符串存储ADMIN和OPERATOR比数字枚举可读性好代码里用常量类统一定义避免到处写魔法值。表关系上order_info和customer是多对一order_info和order_item是一对多sys_user和订单没有直接关联因为具体某个订单是谁接收的这次为了简化没有加receiver_id字段实际业务里可以加上方便考核员工绩效。所有表都建议加上create_time和update_timeMyBatis里用数据库的CURRENT_TIMESTAMP和ON UPDATE CURRENT_TIMESTAMP自动维护Java代码里就不用每次都手动set了。2. 后端核心实现与业务逻辑设计2.1 SpringBoot项目初始化与分层架构SpringBoot项目初始化最简单的方式是去Spring Initializr官网生成一个基础工程勾选Spring Web、MyBatis、MySQL Driver这三个依赖。也可以自己手写pom.xml核心依赖就这么几个dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdorg.mybatis.spring.boot/groupId artifactIdmybatis-spring-boot-starter/artifactId version2.3.1/version /dependency dependency groupIdmysql/groupId artifactIdmysql-connector-java/artifactId scoperuntime/scope /dependency这里有个版本坑mybatis-spring-boot-starter的版本要跟SpringBoot版本匹配。SpringBoot 2.7.x对应的MyBatis starter是2.3.xSpringBoot 3.x就要用mybatis-spring-boot-starter 3.0以上的版本因为javax包名改成了jakarta。建议新手直接用SpringBoot 2.7.x资料多、坑少、兼容性好。项目分层上我坚持用经典的Controller-Service-Mapper三层。Controller只做参数接收和结果返回不写业务逻辑Service层处理业务规则比如计算订单金额、校验订单状态是否可以流转Mapper层只负责SQL操作。有人觉得三层太啰嗦查询简单点的Controller直接调Mapper不行吗从短期看确实省事但一旦业务复杂起来比如一个新增订单的操作要同时插入订单主表和明细表、更新客户累计消费、记录日志没有Service层做事务控制这些操作分散在Controller里就是灾难。配置文件的写法也有讲究。application.yml里把数据源信息、MyBatis的mapper-locations、驼峰命名映射、日志输出全部配好spring: datasource: url: jdbc:mysql://localhost:3306/laundry?useSSLfalseserverTimezoneAsia/ShanghaicharacterEncodingutf8mb4 username: root password: 123456 driver-class-name: com.mysql.cj.jdbc.Driver mybatis: mapper-locations: classpath:mapper/*.xml type-aliases-package: com.laundry.entity configuration: map-underscore-to-camel-case: true log-impl: org.apache.ibatis.logging.stdout.StdOutImplmap-underscore-to-camel-case这个配置非常关键。数据库字段是create_timeJava属性是createTime开启这个配置后MyBatis自动完成映射不用每个字段都手动写resultMap。新手一开始没开这个配置查出来全是null排查半天才发现是对应不上。2.2 MyBatis的Mapper接口与XML映射详解MyBatis使用上最大的难点是理解Mapper接口和XML文件的对应关系。接口里定义一个方法XML里写SQL通过namespace和id匹配。我习惯把所有SQL写在XML里不推荐注解写SQL的方式——一旦SQL复杂起来注解里拼接字符串那叫一个痛苦。以订单状态更新为例接口定义是这样的public interface OrderMapper { int updateStatus(Param(orderId) Long orderId, Param(status) Integer status, Param(finishTime) LocalDateTime finishTime); }对应的XMLupdate idupdateStatus parameterTypemap UPDATE order_info SET status #{status}, if testfinishTime ! null finish_time #{finishTime}, /if update_time NOW() WHERE id #{orderId} /update为什么用动态SQL因为订单从待取衣变成已完成时需要写入finish_time而从待洗涤变成洗涤中时不需要。如果写两个单独的update方法代码就冗余了。动态SQL的if标签可以根据传入参数决定是否拼接某段SQL灵活处理不同状态的更新需求。再来看多表联查的resultMap配置。查询订单列表时我需要把订单信息连同客户姓名一起查出来select idselectOrderWithCustomer resultMapOrderWithCustomerMap SELECT o.id, o.order_no, o.status, o.create_time, o.finish_time, c.id as cid, c.name, c.phone FROM order_info o LEFT JOIN customer c ON o.customer_id c.id ORDER BY o.create_time DESC /selectresultMap配置resultMap idOrderWithCustomerMap typeOrderInfo id propertyid columnid/ result propertyorderNo columnorder_no/ result propertystatus columnstatus/ association propertycustomer javaTypeCustomer id propertyid columncid/ result propertyname columnname/ result propertyphone columnphone/ /association /resultMap这是MyBatis嵌套对象映射的经典写法。OrderInfo类里有一个Customer类型的属性通过association标签将查询结果的cid、name、phone映射到Customer对象里。这里要注意的是如果查询SQL里有两个id字段必须给其中一个起别名否则MyBatis不知道哪个id映射到哪个对象的id属性。分页查询是另一个高频需求。原生的MyBatis写分页SQL要手动拼LIMIT比较繁琐。我引入PageHelper插件一行代码搞定PageHelper.startPage(pageNum, pageSize); PageOrderInfo page orderMapper.selectOrderList(condition);PageHelper底层原理是拦截器在执行SQL之前自动拼接LIMIT语句并且把总条数、总页数等信息封装到Page对象里。用startPage之后紧接着的第一条SQL查询会被拦截分页这是它的工作机制。注意startPage不能被多条查询间隔使用必须紧跟查询语句否则分页会跑到别的SQL上去。2.3 订单状态流转与事务控制的设计思路订单状态管理是洗衣店系统的灵魂。笨拙的做法是前端根据按钮直接调用不同的接口比如点击“开始洗涤”就调这个接口点击“完成洗涤”就调那个接口。灵活但不够安全的做法是后端定义一个统一的状态流转接口前端只传目标状态后端校验当前状态是否允许跳转。我选择后者因为状态机的合法性必须由后端保证前端传错了数据不会导致脏数据。状态流转的校验逻辑核心代码public void updateOrderStatus(Long orderId, Integer targetStatus) { OrderInfo order orderMapper.selectById(orderId); if (order null) { throw new BusinessException(订单不存在); } // 校验状态流转合法性 if (!canTransfer(order.getStatus(), targetStatus)) { throw new BusinessException(订单状态不允许由 order.getStatus() 变更为 targetStatus); } LocalDateTime finishTime null; if (targetStatus.equals(OrderStatus.COMPLETED)) { finishTime LocalDateTime.now(); } orderMapper.updateStatus(orderId, targetStatus, finishTime); }canTransfer方法维护一个状态转移矩阵。比如待洗涤(0)只能流转到洗涤中(1)洗涤中(1)只能流转到质检中(2)质检中(2)可以流转到待取衣(3)或者因为质检不过退回洗涤中(1)待取衣(3)只能流转到已完成(4)或者已取消(5)。已取消和已完成是终态不能再往任何状态跳。事务控制的重点在创建订单这个操作上。创建订单不是插入一条记录那么简单它是一个聚簇操作插入订单主表、插入订单明细表、如果客户是首次消费则新建客户否则更新客户累计消费金额。这四个操作必须在同一个事务里要么全部成功要么全部失败。实现方式是在Service方法上加Transactional注解Spring声明式事务会开一个数据库事务任何一步抛出RuntimeException前面已执行的SQL全部回滚。这里有个细节容易踩坑Transactional默认只对RuntimeException回滚对checked exception不回滚。如果业务代码里catch了异常并转成了自定义的BusinessException继承了RuntimeException没问题但如果你catch了Exception后打印日志再抛出且这个Exception是IOException这类受检异常事务是不会回滚的。所以规范做法是业务代码里不要吞异常自定义业务异常统一继承RuntimeException。还有一种情况是自调用导致事务失效。比如Service里methodA没有Transactional它调用了同类里带Transactional的methodB此时methodB的事务是不会生效的。因为事务是通过AOP动态代理实现的自调用走的是this对象没有经过代理对象。解决办法有两种要么让方法间的调用都经过Controller层要么在类上注解Transactional。2.4 RESTful接口设计与统一返回格式前端要跟后端打交道接口设计不规范前端开发就会很痛苦。我采用的是RESTful风格加统一响应体。统一响应体结构public class ResultT { private Integer code; // 200成功500失败 private String message; // 提示信息 private T data; // 响应数据 }所有接口返回Result对象成功时code200业务异常时code500并且message携带原因。前端axios拦截器统一处理这个结构code200时取data渲染页面code500时弹出message提示。这样前后端联调时错误信息能直接透传到页面上排查问题效率很高。订单相关接口列举方法路径功能参数POST/api/order/create创建订单订单JSONGET/api/order/list分页查询订单pageNum, pageSize, status, orderNoGET/api/order/detail/{id}订单详情路径参数idPUT/api/order/status更新订单状态orderId, targetStatusDELETE/api/order/{id}删除订单路径参数id创建订单接口的请求体设计{ customer: {name: 张三, phone: 13800138000}, items: [ {itemName: 羽绒服, serviceType: 干洗, price: 50}, {itemName: 牛仔裤, serviceType: 水洗, price: 20} ], note: 羽绒服需要去渍 }接口接收这个请求后Service层要做的事情是先查phone对应的客户是否存在存在就取customer_id不存在就新建客户然后计算总金额插入order_info再遍历items插入order_item最后用DB事务保证一致性。返回的data是订单号order_no前端拿到后让用户核对这个号码作为取衣凭证。关于参数校验null值的校验放在Controller层用Valid注解实现但业务校验比如价格是否合理、状态能否流转要放在Service层。两层的分工要明确不能全堆在Controller里那会让Controller变得臃肿不堪。3. 前端Vue实现与交互细节3.1 Vue工程搭建与项目目录规划前端工程我推荐用Vue CLI或者Vite来初始化。Vite现在更主流启动速度快、热更新流畅。初始化命令很简单npm create vitelatest laundry-front -- --template vue安装依赖基础必备的是vue-router路由、pinia或vuex状态管理、axiosHTTP请求、element-plusUI组件库npm install vue-router4 pinia axios element-plus项目的目录结构规划src/ api/ // 接口封装每个模块一个文件order.js、customer.js、user.js components/ // 复用组件订单状态标签、分页组件、搜索栏 router/ // 路由配置文件 store/ // Pinia状态管理用户信息、Token views/ // 页面视图OrderList.vue、OrderDetail.vue、CustomerList.vue、Dashboard.vue、Login.vue utils/ // 工具函数axios封装、格式化方法 App.vue main.jsapi目录的封装很关键。以order.js为例import request from /utils/request export function createOrder(data) { return request.post(/api/order/create, data) } export function getOrderList(params) { return request.get(/api/order/list, { params }) } export function updateOrderStatus(data) { return request.put(/api/order/status, data) }这样做的目的是把接口地址和参数格式统一收敛在两个文件里。后端接口一改前端只需要改api/order.js这一个文件不用在页面里满世界找那些散落的axios调用。页面组件里引入这些函数直接用代码干净很多。3.2 订单列表页与状态流转的组件化实现订单列表页是前台用得最频繁的界面要支持按状态、订单号、手机号筛选还要支持分页展示。我用Element Plus的el-table组件展示订单数据el-select做状态下拉筛选el-pagination做分页。订单状态在数据库中是数字枚举前端展示时需要转换为可读的中文标签和对应颜色的Tag标签。我在components里写了一个OrderStatusTag组件props接收status数值内部映射表const statusMap { 0: { label: 待洗涤, type: warning }, 1: { label: 洗涤中, type: primary }, 2: { label: 质检中, type: info }, 3: { label: 待取衣, type: success }, 4: { label: 已完成, type: success }, 5: { label: 已取消, type: danger } }组件化之后订单列表、订单详情、订单搜索框里都能复用这个状态标签组件。状态流转的操作按钮根据当前状态动态渲染待洗涤的订单展示“开始洗涤”按钮洗涤中的展示“完成洗涤送入质检”和“质检不合格退回”按钮质检中的展示“质检通过入库”按钮待取衣的展示“确认取衣结账”按钮。前端状态流转交互的核心是一个Dialog弹窗点击操作按钮后弹出确认框调用后端updateOrderStatus接口成功后刷新列表数据。这里有个体验细节操作成功之后如果用户正在第2页刷新完数据应该还在第2页而不是跳回第1页。实现方案是保存当前的pageNum和pageSize刷新列表时重新请求时带上这两个参数。3.3 axios封装与拦截器的实际用法axios封装是整个前端工程的质量分水岭。新手通常直接在页面里import axios然后调api但一旦要处理Token、统一错误提示、加载动画这些横切关注点的时候代码就会乱成一锅粥。我在utils/request.js里做了一套统一封装关键代码是请求拦截器和响应拦截器const service axios.create({ baseURL: /api, timeout: 10000 }) // 请求拦截器自动附加Token service.interceptors.request.use(config { const token localStorage.getItem(token) if (token) { config.headers[Authorization] Bearer token } return config }) // 响应拦截器统一处理业务码和HTTP错误 service.interceptors.response.use( response { const res response.data if (res.code 200) { return res.data } else { ElMessage.error(res.message) return Promise.reject(new Error(res.message)) } }, error { ElMessage.error(网络请求失败请检查后端服务是否启动) return Promise.reject(error) } )请求拦截器里加Token是管理系统的标配。前端登录成功后端返回一个Token串前端存到localStorage里。后续每个请求自动带上这个Token后端通过过滤器校验没Token的直接返回401。这样前端拿到的res就是后端返回的data字段页面代码不用每次判断code清爽很多。响应拦截器的作用更大。后端业务异常时code500、message订单状态不允许变更前端不用在每个页面写try-catch处理错误提示拦截器统一弹ElMessage提示页面代码只需要处理成功分支的数据即可。3.4 统计看板的图表实现思路统计看板是老板视角的首页我用ECharts画了三个图表近7天营收折线图、今日订单状态分布饼图、服务项目销量占比柱状图。前端安装echarts依赖在Dashboard.vue里引入import * as echarts from echarts图表数据从后端的统计接口拿。后端SQL示例SELECT DATE_FORMAT(create_time, %Y-%m-%d) as day, SUM(total_amount) as total FROM order_info WHERE status 4 AND create_time DATE_SUB(CURDATE(), INTERVAL 6 DAY) GROUP BY DATE_FORMAT(create_time, %Y-%m-%d)这个SQL的作用是统计最近7天已完成订单的每日营收。CURDATE()取当前日期DATE_SUB往前推6天GROUP BY按天聚合。前端拿到数组后map出日期和金额两个数组塞给ECharts的xAxis和series就能渲染。服务项目销量占比的SQLSELECT oi.service_type, COUNT(*) as cnt, SUM(oi.price) as amount FROM order_item oi LEFT JOIN order_info o ON oi.order_id o.id WHERE o.create_time DATE_SUB(CURDATE(), INTERVAL 30 DAY) GROUP BY oi.service_type这里用LEFT JOIN关联订单主表是为了过滤掉未完成订单的数据。如果直接查order_item表会把已取消订单里的明细也算进去统计就不准了。4. 前后端联调、鉴权与部署实战4.1 跨域问题的两种典型解决方案前后端分离开发时前端跑在8080端口后端跑在8081端口两个端口不同浏览器就会触发跨域限制。最常见的解决方案两种后端加CORS配置或者前端配Vite代理。我推荐在开发环境用Vite代理生产环境用Nginx反向代理两者结合最顺畅。Vite配置代理的方式在vite.config.js里server: { port: 3000, proxy: { /api: { target: http://localhost:8081, changeOrigin: true } } }配置之后前端请求/api/order/listVite开发服务器会把这个请求转发到http://localhost:8081/api/order/list浏览器端看到的始终是同一个origin跨域被完美规避。这个方案的妙处在于前端代码里写的baseURL还是/api不用区分环境到了生产环境Nginx配置同样的转发规则就够了。如果要用后端CORS方案写一个WebMvcConfigurer配置类Configuration public class CorsConfig implements WebMvcConfigurer { Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping(/api/**) .allowedOrigins(http://localhost:3000) .allowedMethods(GET, POST, PUT, DELETE) .allowedHeaders(*); } }两种方案注意生产环境不要用CORS的allowedOrigins(*)这会让任何网站的脚本都能调用你的后端接口配合CSRF攻击就是灾难。用Vite代理加Nginx统一解决跨域不暴露后端真实地址安全性更好。4.2 登录鉴权与后端拦截器实现登录接口的设计前端提交用户名密码到POST /api/user/login后端查询sys_user表用BCrypt匹配密码匹配成功则生成一个Token返回。Token我用的是简单有效的UUID方案把Token存到Redis里并设置过期时间后续请求通过拦截器校验Token是否有效。用Redis存Token的好处是管理员可以随时踢人下线只要删掉Redis里的Token键就行。当然如果不想引入Redis把Token信息加密签名放进JWT里也是一个标准方案两者各有取舍。后端拦截器实现我写一个HandlerInterceptorpublic class AuthInterceptor implements HandlerInterceptor { Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { String token request.getHeader(Authorization); if (token null || token.isEmpty()) { response.setStatus(401); response.getWriter().write({\code\:401,\message\:\未登录\}); return false; } // 校验Token有效性从Redis中查询 String username redisTemplate.opsForValue().get(TOKEN_ token); if (username null) { response.setStatus(401); response.getWriter().write({\code\:401,\message\:\登录已过期\}); return false; } request.setAttribute(username, username); return true; } }在WebMvcConfigurer里注册拦截器并配置放行路径registry.addInterceptor(authInterceptor) .addPathPatterns(/api/**) .excludePathPatterns(/api/user/login)注意放行逻辑登录接口必须放行不然没人能登录进来。前端收到401响应后axios拦截器统一处理跳回登录页并清空本地Token状态。这个闭环设计在整个系统里是最容易被忽略的很多项目做到接口能通就宣布完成结果刷新页面Token失效了也不知道该怎么处理。4.3 前后端打包与Linux服务器部署要点前端打包执行npm run build生成dist目录。后端打包执行mvn clean package生成可执行的Jar包。传统做法是把dist目录复制到Nginx的html目录下然后Nginx配置反向代理把/api开头的请求转发到后端端口。Nginx的关键配置片段server { listen 80; server_name yourdomain.com; location / { root /var/www/laundry/dist; index index.html; try_files $uri $uri/ /index.html; } location /api/ { proxy_pass http://localhost:8081; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } }try_files这行配置是为了配合Vue Router的history模式。如果不加这行刷新前端页面时Nginx会返回404因为浏览器请求的路径在后端文件系统里不存在try_files会把所有未知路径都指向index.html由Vue Router接管路由跳转。后端启动用nohup命令nohup java -jar laundry-system-1.0.0.jar --server.port8081 app.log 21 这里有个运维细节jar包里已经通过application.yml配置了端口但生产环境可以用命令行参数覆盖这样一台服务器上跑多套系统时互不干扰。日志重定向到app.log排查问题直接tail -f app.log看异常堆栈。5. 开发与部署中的常见问题速查问题现象可能原因解决方案前端请求后端接口404代理路径配错或后端context-path不一致检查vite.config.js的proxy.target是否指向正确端口后端配置里没有配context-path前端请求路径不要加额外前缀时间字段返回差8小时JDBC连接串没配serverTimezone或Jackson时区设置不符URL加上serverTimezoneAsia/ShanghaiSpringBoot配置jackson.time-zoneGMT8MyBatis查询结果全是null驼峰映射没开启mybatis.configuration.map-underscore-to-camel-case设为true用resultMap时检查column与property是否对应更新订单状态时报“订单状态不允许变更”前端传递的状态顺序不符合状态机定义后端打印日志查看当前订单状态按状态机规则调整前端按钮的状态传递逻辑启动后端报端口被占用8081端口被其他进程占用使用命令netstat -ano打包后前端页面白屏资源路径配置不对或路由模式问题Vue项目构建时设置base路径或者改用hash模式MySQL连接报SSL错误MySQL8默认启用SSL但客户端没配证书URL加useSSLfalse本地开发足够生产建议配证书再说几个代码层面的避坑心得。第一个是MyBatis动态SQL里的逗号拼接问题。在update语句中如果用标签MyBatis会智能去掉最后一个逗号但如果你自己在SQL里写容易多出一个逗号导致SQL语法错误update idupdateCustomer UPDATE customer set if testname ! nullname #{name},/if if testphone ! nullphone #{phone},/if /set WHERE id #{id} /update第二个是前端时区显示问题。后端返回LocalDateTime默认序列化成ISO格式前端直接用toString显示没问题但如果需要转换成“2025-06-15 14:30”这种格式要在前端utils里写格式化函数统一转换别在页面里用Date对象的getHours方法时区协商会让你怀疑人生。第三个是列表分页的total字段类型。PageHelper返回的总数是一个long类型前端接收时如果是JSON序列化默认转成number没问题但如果数据量超过2^53精度会丢失所以total字段在后端Result里最好用Long类型明确返回。6. 经验总结与后续扩展方向我把这个系统从零搭起来前后花了一周多的时间最深的体会是管理系统的复杂度和技术含量不在某个单一技术点上而在业务逻辑的完整性。比如订单状态的流转规则如果一开始不画清楚状态机写代码时就会反复改再比如客户金额累计如果不用事务控制一次异常就会导致金额不对。建议做这个项目时先把数据库表结构和状态机画出来吃透了再动手写代码顺序不能反。对于想拿这个项目做毕业设计的同学我有几个实用建议。第一答辩时间控制在15分钟以内演示的核心顺序不要乱先演示登录和权限控制再演示创建订单的完整流程然后演示订单状态流转最后打开统计看板展示数据趋势。第二把MyBatis动态SQL、事务控制、拦截器鉴权这几个点背熟老师大概率会问为什么用MyBatis不用JPA、事务是怎么实现的。第三在数据库设计上可以加一个订单日志表记录每次状态变更的操作人和时间答辩时能加不少分因为这说明你考虑了审计需求。以后想在这个系统上扩展的话可以加微信小程序端顾客线上下单、查看订单进度可以对接短信服务订单完成时自动发送取衣通知可以加会员积分和充值功能配合客户表里的level字段做营销活动。还有一点洗衣店常常有集中送洗的团单比如酒店布草洗涤这种B端订单和C端零售单在结算方式上差异很大如果能做成多类型订单系统的实用价值会上升一个档次。技术上这些扩展基本都能在现有表结构上平滑演进不会伤筋动骨。