最近帮朋友的小洗衣店折腾了一套订单管理系统,从最开始的手工记账,到后来用Excel,再到最后决定自己动手写一套前后端分离的完整系统。这一路踩了不少坑,也总结了一些实战经验。趁着周末空下来,把整个项目的核心思路、技术选型、关键实现和调试心得都整理出来,希望能给正在做类似毕业设计或者小型商业项目的朋友一点参考。
这套系统用的是目前国内中小型项目里非常主流的一套组合:Java SpringBoot做后端服务,Vue3做前端界面,MyBatis负责数据库操作,MySQL作为持久化存储。四个技术栈各司其职,合在一起就是一个订单从创建、洗衣、完成到取走的全流程闭环管理。相比那些什么都往单体JSP页面里塞的老项目,这种前后端分离的架构至少有两个很直接的好处:一个是前端开发和后端开发可以彻底并行,另一个是后期就算要换Web端或者对接小程序,后端接口基本不需要大动。
先说一下这个系统的定位。洗衣店订单管理系统,核心其实就是围绕“订单”这个聚合根来做文章。店里的顾客来了,要记录他送洗了什么衣物、什么洗涤方式、什么时候要取、收多少钱;衣物进车间后,要标记状态是正在洗还是已经洗完;顾客来取的时候,要快速核销订单。再加上每件衣物还要按种类管理,比如羽绒服、西装、毛毯,洗法不一样,价格也不一样。所以系统的数据模型其实不复杂,但业务状态流转特别容易做得混乱。有些同学一上来就建了二十多张表,字段多到看半天不知道哪个是主键,反而把简单事情复杂化了。我这个项目的经验是,先把订单主表、订单明细表、衣物分类表、客户表这四张核心表设计好,其余的都是锦上添花。
1. 项目整体设计与技术选型思路
1.1 为什么选SpringBoot+Vue3而不是SSH或者纯JSP
很多人问,一个洗衣店管理系统而已,用得着前后端分离吗?我的回答是,如果你只想交个作业,那JSP+Servlet确实够用,但如果你想做一套真正能在店里跑起来、后续能继续扩展的系统,前后端分离是值得的。SpringBoot最大的优势是内置了Tomcat,打一个jar包就能跑,不需要单独装服务器中间件,也不要像SSH时代那样配一堆XML配置文件。Maven管理依赖也比手动拷贝jar包省心太多。
Vue3这边,Composition API是我特别推荐用起来的。以前写Options API,数据都散在data、methods、computed里,一旦页面复杂度上来,逻辑跳来跳去很难维护。用了Composition API之后,可以把订单列表的查询、筛选、状态更新这些逻辑全部封装到一个自定义函数里,页面里只需要引用这个函数就行。这个特性在订单管理这种大量列表交互的场景里,体验差距特别明显。
1.2 系统模块边界划分
我在设计这个项目的时候,把所有功能分为四个大块。第一块是订单中心,包含新建订单、订单列表、状态流转、订单核销;第二块是客户管理,负责会员信息的登记、累计消费次数以及联系方式快速检索;第三块是衣物分类与价格管理,洗衣店通常有明码标价的需求,不同衣物不同类型价格不同;第四块是统计报表,简单统计每天、每周的单量和营业额。这个划分既能够覆盖洗衣店日常经营的绝大多数场景,又不至于一开始就把权限管理、多门店、员工排班这些复杂功能塞进来。做项目最忌讳一上来就追求大而全,先把核心链路跑通,再迭代扩展,这个原则在真实场景里非常重要。
1.3 为什么选MyBatis而不是JPA
其实对于这种表关系比较简单的系统,Spring Data JPA也挺合适,但我最后还是选了MyBatis。原因有两个。一是MyBatis对SQL的控制力更强,订单列表要做多条件动态查询,用Provider或者XML里的if标签都非常直观,写出来的SQL我自己能完全掌控;第二个原因是国内公司用MyBatis的比例实在太高了,这套技术栈练熟了,以后找工作或者接手别的项目会省很多力气。
2. 数据库设计与服务端实现
2.1 MySQL表结构设计的取舍
数据库我用的MySQL 8.0,字符集选了utf8mb4而不是utf8,这个很重要。utf8在MySQL里其实是utf8mb3,存不了emoji,而且有些生僻字也会报错。客户端店名、顾客备注这些字段,一旦有特殊字符,utf8就容易翻车。
订单主表我命名为t_order,核心字段包括订单编号、客户ID、订单状态、总金额、创建时间、取衣时间、备注。订单明细表t_order_item,记录每件衣物的分类ID、衣物描述、洗涤方式、单价和数量。客户表t_customer和衣物分类表t_clothes_type分别记录基本信息。四张表之间用订单ID和客户ID关联,不搞多对多,洗衣店业务里也没有多对多的需求。
订单编号的生成方式我用的是时间戳+随机数的组合,类似“202502071530001234”。为什么不用数据库自增ID直接暴露给前端?因为订单编号是一个业务概念,很多时候要在电话里给顾客报单号,太长太乱容易报错。我采取的方式是日期加四位流水号,同一天内自增,跨天归零。这样顾客报号码只需要报最后四位就能快速定位,实测非常方便。
资金相关的字段我统一用了DECIMAL(10,2),比如订单总金额、单价、应收金额。有些初学者图省事用DOUBLE存钱,这是大忌。DOUBLE有精度问题,0.1+0.2算出来可能是0.30000000000000004。一旦涉及金额计算,必须用DECIMAL。
2.2 MyBatis动态SQL与结果映射细节
订单列表页是系统里最核心的页面,搜索条件通常有订单状态、客户名称、时间段、订单编号模糊查询。这种场景用MyBatis的动态SQL来处理非常清爽。举一个例子:
<select id="pageOrder" resultType="com.laundry.entity.Order"> SELECT o.id, o.order_no, o.status, o.total_amount, o.create_time, c.name AS customerName FROM t_order o LEFT JOIN t_customer c ON o.customer_id = c.id <where> <if test="status != null"> AND o.status = #{status} </if> <if test="customerName != null and customerName != ''"> AND c.name LIKE CONCAT('%', #{customerName}, '%') </if> <if test="startTime != null"> AND o.create_time >= #{startTime} </if> <if test="endTime != null"> AND o.create_time <= #{endTime} </if> </where> ORDER BY o.create_time DESC </select>这个SQL里有个细节很多人第一次写会踩坑:resultType="com.laundry.entity.Order",Order类里如果只有id、orderNo、status、totalAmount、createTime,那么customerName就映射不了。我以前的做法是在实体类里加一个customerName字段,然后开启MyBatis的驼峰映射开关。因为数据库字段是下划线风格,Java属性是驼峰风格,如果不开自动映射,查出来的customerName在Java里就是null。根本解决办法是在application.yml里配置:
mybatis: configuration: map-underscore-to-camel-case: true有了这个配置,order_no、create_time这些字段才能自动赋到Order对象的orderNo、createTime属性上,省掉一大串resultMap。
2.3 SpringBoot接口设计与状态机流转
后端接口设计遵循RESTful风格,订单相关的接口这样规划:
- POST /api/order:新建订单,同时插入订单主表和订单明细表,在一个事务里完成。
- GET /api/order/page:分页查询订单,支持状态、客户名、时间段筛选。
- PUT /api/order/{id}/status:更新订单状态。
- GET /api/order/statistics:营业额统计。
- DELETE /api/order/{id}:删除订单,一般只允许删除待接收状态的数据。
订单状态的流转我定义了一个枚举,包括待接收(0)、洗涤中(1)、已完成(2)、已取走(3)、已取消(4)。状态只能往后流转,不能乱跳,比如待接收的订单不能直接改成已取走。我在Service层写了一个状态机校验方法,每次更新状态前先校验当前状态和目标的组合是否合法。很多人觉得状态机是过度设计,但实际运营中如果顾客衣服还在洗,店员却误操作点了已取走,后面排查起来非常麻烦。有了状态校验,从源头上就把低级错误挡掉了。
创建订单这个接口,前后端联调最容易出问题的是时间格式。前端传过来的是"2025-02-07 15:30:00"这种字符串,如果后端接收参数用的是Date类型,SpringBoot默认的Jackson反序列化可能报错或者解析失败。我的做法是在application.yml里统一配置时间格式:
spring: jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: GMT+8因为MySQL连接串,我加了一个serverTimezone=Asia/Shanghai的参数。如果不加,JDBC连接时会报UTC和本地时区差8个小时的错误,或者出现时间数据入库后早8小时的情况。这两处配置是前后端时间对不齐的万金油解法。
2.4 MyBatis一级缓存导致的数据不一致
MyBatis的一级缓存是SqlSession级别的,默认开启。这在某些场景下会坑人,比如连续两次查询同一条订单,第一次查出来状态是洗涤中,第二次查询之前MySQL里的数据已经被另一个会话改成了已完成,但第二次查询因为走了一级缓存,返回的结果还是洗涤中。尤其在做订单状态管理这种更新频繁的系统里,一级缓存对数据一致性的影响不小。
我的解决方式有两种,最简单粗暴的是在需要强一致性的查询方法上加上flushCache="true",也就是这个查询执行前强制清空一级缓存。或者直接在Service层把查询方法标记为@Transactional(readOnly = true)。注意,在实际项目中,一级缓存只能保证同一个SqlSession内的数据一致,跨请求根本不存在共享问题。所以只要搞清楚了作用域,就不会被它坑太狠。
3. 前端Vue3实现与前后端联调
3.1 Vue3工程初始化和目录结构规划
前端我用Vite作为构建工具。相比Webpack,Vite在开发环境的启动速度几乎秒开,热更新的体验好一个档次。创建工程的命令很简单:
npm create vite@latest laundry-frontend -- --template vue-ts我选择了JavaScript而不是TypeScript,不是说TS不好,而是考虑到这个项目的定位是快速开发和演示,JS能省掉不少类型声明的代码量。如果团队规模大、需求变化频繁,那还是上TS更稳。
前端的目录结构,我是按业务模块划分的,不是按文件类型划分。比如views目录下面就直接分OrderList.vue、CustomerList.vue、ClothesType.vue、Dashboard.vue。有人说这样复用性差,但对于业务页面来说,按模块维护的直观性远远重要于代码复用。组件抽取的粒度,我的经验是同一个功能代码在三个以上页面出现才考虑提炼出公共组件,否则硬抽组件只会让父子通信的链路变长,排查问题更痛苦。
3.2 订单创建页面与Composition API的实际运用
订单创建页面是整个前端开发中工作量最大的部分。它有客户选择、衣物明细的动态添加、洗涤方式选择、金额自动计算、提交时校验等多个交互逻辑。如果全部堆在setup里写,代码很容易变得乱糟糟。我在代码里把整个表单逻辑抽成了两个自定义组合式函数:useOrderForm.ts和useOrderSubmit.ts。
useOrderForm负责表单状态管理,包括客户搜索选中的客户信息、衣物明细用的reactive数组、添加明细、删除明细、根据衣物分类联动计算单价、实时算合计。useOrderSubmit则负责调用后端接口,处理提交中的loading状态和错误提示。页面组件只负责把这些函数返回的数据解构出来绑定到表单控件上。这样做的核心思想是状态逻辑和视图解耦。以后就算把模板从Element Plus换成别的组件库,业务逻辑一行都不用改。
代码的大致结构是这样的:
const { formData, itemList, addItem, removeItem, totalAmount } = useOrderForm(); const { submitting, submitOrder } = useOrderSubmit(formData, itemList);函数内部的addItem正常情况下是往一个reactive的数组里push一个空对象。这里有个很典型的Vue3响应式陷阱:如果用普通数组加上改length的方式去更新,视图不会刷新。reactive包裹的数组,必须用push、splice这些变异方法来操作,直接赋值或者直接itemList[itemList.length] = {}都不会触发响应。这个坑,Vue3初学者基本都会踩一次。
3.3 订单列表页面的筛选、分页与联动
订单列表页的筛选条件绑定了几个表单控件,点击查询按钮时重新请求第一页的数据。分页组件我用的Element Plus的el-pagination,一个非常典型的交互模式:
<el-pagination v-model:current-page="queryParams.pageNum" v-model:page-size="queryParams.pageSize" :total="total" @current-change="fetchOrderList" @size-change="handleSizeChange" />后端接口返回的数据结构,我统一封装成了{ code: 200, data: { records: [], total: 100 }, msg: "success" }。前端在request.js的axios响应拦截器里统一拦截code,如果不是200就直接弹message提示,业务代码里就不需要每个接口都去判断成功失败了。这个封装看起来简单,但它能让之后几十个接口的开发节省大量的重复代码。
订单列表每一行我放了一个“状态流转”按钮,点击后弹确认框,调后端的PUT接口更新状态。这类操作要防止用户连续点击导致重复请求。我的做法是在请求拦截器里给每个请求生成一个队列,同一个请求如果还在pending状态,就阻止第二次发送。也可以用防抖,但对于状态流转这种必须保证一次成功的操作,防抖不如直接加loading禁用按钮稳妥。
3.4 Axios请求封装与跨域调试
前后端分离开发时,最大的痛点就是跨域。前端跑在5173端口,后端跑在8080端口,浏览器在同源策略下默认是不允许彼此通信的。开发环境里,我的解决方法是利用Vite的代理,在vite.config.ts里配置:
server: { proxy: { '/api': { target: 'http://localhost:8080', changeOrigin: true } } }这样前端代码里请求/api/order/page,Vite开发服务器会自动把请求转发到后端的8080端口,完全绕开跨域问题。生产环境部署时,把前端build出来的dist目录扔到Nginx里,再配置一个location /api的反向代理指向后端服务,思路同开发环境完全一致。
有些人在后端直接加@CrossOrigin注解或者配全局CORS,虽然开发时能通,但生产环境如果走了Nginx,这些配置反而可能造成混乱。我的原则很简单:开发环境靠Vite代理,生产环境靠Nginx反向代理,后端代码里不做任何跨域处理。
3.5 Element Plus表单校验的细节处理
订单表单里,客户名是必选,衣物明细至少有一件,洗涤方式必须选。Element Plus的表单校验用的是async-validator,我直接把校验规则定义在表单规则里:
const rules = { customerName: [{ required: true, message: '请选择客户', trigger: 'change' }], clothesItems: { type: 'array', required: true, min: 1, message: '至少添加一件衣物', trigger: 'change' } }有个细节是trigger类型。下拉选择、日期选择的校验trigger用change,输入框的校验trigger用blur。如果把trigger统一写成change,输入框内容清空后一失焦,校验并不触发,表单提交时才会提示,这种交互体验非常差。我一开始就把所有trigger都设置成change,后来测试发现输入框的实时校验体验反而很奇怪,改成blur之后流畅多了。
4. 核心功能点拆解与实现
4.1 订单状态流转的前端展示后端校验
订单状态的展示,前端是拿一个tag标签来显示不同颜色,待接收是灰色,洗涤中是蓝色,已完成是绿色,已取走是橙色,取消是红色。这个展示本身不复杂,但牵扯到一个问题:订单列表接口返回的状态字段是数字0到4,前端不能直接显示数字,需要一个映射函数转换。我自定义了一个OrderStatus.ts模块,导出一个包含所有状态枚举和标签样式的常量数组,前端组件里直接引用。这样修改状态文案时不需要去页面里到处找字符串,集中管理非常高效。
更重要的是后端对状态流转的约束。如果前端直接调PUT /api/order/{id}/status,参数传一个跟当前状态毫无关联的目标状态,后端必须能拦住。我在OrderServiceImpl里写了一个checkStatusTransition方法:
private final Map<Integer, Set<Integer>> TRANSITION_MAP = new HashMap<>(); { TRANSITION_MAP.put(0, new HashSet<>(Arrays.asList(1, 4))); TRANSITION_MAP.put(1, new HashSet<>(Arrays.asList(2, 4))); TRANSITION_MAP.put(2, new HashSet<>(Collections.singletonList(3))); }意思很明白,待接收只能流转到洗涤中或取消,洗涤中只能到已完成或取消,已完成只能到已取走。其他任何组合都直接抛业务异常。这种集中管理的好处是,如果以后洗衣店有“质检不通过退回洗涤”的需求,只需要在TRANSITION_MAP里加一条2到1的映射,不需要去各个业务流程的代码里找。
4.2 多条件动态统计报表的实现思路
统计报表模块主要是两个数据:今日单量和今日营业额。接口本身只有一句话,但数据口径是一个容易被忽略的问题。我做过几个版本,一开始直接在SQL里用WHERE create_time > CURDATE(),但是MySQL的CURDATE()返回的是当天0点0分0秒,这个没问题。然而不同员工在晚上查询时,可能会因为时区问题查出两条数据。核心还是确保整个系统统一使用Asia/Shanghai时区,不然后端启动的JVM时间和数据库时间不在一个频道,统计结果就全是错的。
除了今日数据,统计报表还要支持一个到七个自然日的数据趋势。我在前端用ECharts的折线图展示,后端用日期分组查询:
SELECT DATE(create_time) as day, COUNT(*) as orderCount, SUM(total_amount) as amount FROM t_order WHERE create_time BETWEEN #{startTime} AND #{endTime} GROUP BY DATE(create_time)这里有个优化细节。BETWEEN的查询如果没有索引,走全表扫描,到数据量过万会明显变慢。我给t_order表的create_time加了一个普通索引,实测在十万条数据量级的订单表上,这个统计查询依然能保持在几十毫秒内返回。
4.3 衣物分类管理对前端联动的影响
衣物分类管理是洗衣店系统的一个特色功能。一件衣物有名称、颜色、材质、洗涤方式、单价几个属性。前端订单明细在添加衣物时,会先调一个接口获取所有衣物分类,渲染到一个下拉框。用户选择衣物分类后,单价自动带出,同时洗涤方式也根据分类里的约定自动选择。
这里我用到了一个Vue3的经典写法:watch监听衣物明细数组里每一项所选分类的id,变化后自动去取对应分类的单价并赋给当前项的单价字段。注意vue的watch如果监听的是数组嵌套对象的属性,deep选项要设为true,但deep会比较消耗性能。如果明细最多十项,完全可以不用deep,直接给下拉框的change事件绑定处理函数,在事件发生时就更新单价,比watch更直接也更高效。
4.4 客户管理的会员时长统计与快速检索
客户模块不复杂,但有一个体验非常关键的点是快速检索。店里常见的场景是顾客报手机号后四位,店员在客户列表输入后四位,直接定位到客户。所以客户表里我加了phone字段和索引,查询做模糊匹配:
SELECT * FROM t_customer WHERE phone LIKE CONCAT('%', #{keyword}, '%')如果数据量大,这个前导通配符会导致索引失效。但洗衣店的客户量一般只有几百到几千,全表扫描都毫无压力。这种场景我就没有刻意去优化索引,实际运行效果很好。另外客户表里我加了累计消费金额和累计消费次数两个字段,每次订单创建时在事务里同步更新。这样客户列表页面就可以看到一个客户常不常来、贡献多少流水,支持店长统计老客户的回馈策略。
5. 常见问题与排查技巧实录
5.1 前后端联调时的CORS和404问题
跨域问题的处理我在上文已经给过方案,但这里要单独强调一个很多人容易犯的错误。如果前端配了Vite代理,后端也配了CORS,两种机制会叠加发生一些奇怪现象,比如一次请求发两次OPTIONS预检,浏览器控制台因为Access-Control-Allow-Origin冲突报错。我最终把后端的跨域配置全部删掉了,只保留Vite代理,整个世界清净了。如果你用的是Swagger测试后端接口,建议在Swagger配置上也加上完整路径,否则联调阶段很容易因为路径不对找不到接口。
还有一个非常常见的404问题:SpringBoot的接口路径和前端axios请求的URL对不上。比如后端接口写的是/api/order/page,前端请求写成了/api/order/page/,多一个斜杠直接404。这类问题其实很好排查,打开浏览器F12,Network面板看一下请求的完整URL,和后端Controller里的@RequestMapping值对照一遍就知道了。
5.2 MyBatis返回字段为null的排查方法
订单列表和客户列表都出现过某几个字段查出来是null的情况。排查思路我总结为三步。第一步,SQL在数据库客户端里直接跑,看有没有数据;第二步,如果数据库查询有值,而接口返回null,看看实体类字段名和查询结果的列名能不能对应上;第三步,检查map-underscore-to-camel-case配置是否开启。绝大部分null问题都出在这三步里。
有一个特别容易忽视的是,如果实体类里某个字段根本不是表字段,而是联表查询出来的,比如订单实体类的customerName。这种字段如果没开启驼峰映射,即使数据库查出来的列别名是customer_name,Java里也赋不上值。要么把SQL里的别名改成和实体类属性一模一样的customerName,要么开启全局映射。我建议就是全局开,一劳永逸。
5.3 跨天订单数据统计错误的案例
有一次测试时发现,早上九点统计昨天单量比实际少了三单。查了半天发现是时间过滤条件写得太粗糙,只用了create_time BETWEEN '2025-02-06' AND '2025-02-07'。因为2025-02-07这个字符串在MySQL里会被隐式转换成2025-02-07 00:00:00,所以昨天最后一小时的订单全部被漏掉了。排查方法很简单,统计口径改为:
DATE(create_time) = '2025-02-06'或者用>= '2025-02-06 00:00:00' AND < '2025-02-07 00:00:00'。这个坑在凌晨的时间点附近特别容易发作,以后写任何带时间的条件,都要问一句“这一天的结束时间到底包不包括晚上23点59分59秒”。
5.4 SpringBoot版本过高导致的不兼容问题
我最初创建项目时用了当时最新的SpringBoot 3.x版本,结果用MyBatis-Spring-Boot-Starter时碰上了兼容性问题。很多老版本Starter是基于javax命名空间的,而SpringBoot 3.x用了jakarta命名空间,直接启动报错ClassNotFoundException: javax.servlet.Filter。解决办法是升级Starter到对应的3.x版本,或者干脆用SpringBoot 2.7.x。作为个人项目,稳定性优先,我最后退回使用了2.7.x版本。现在想想这个决定是对的,新版本虽有新特性,但对于这种业务比较固定的中小型系统,成熟稳定的生态更重要。
5.5 MySQL连接配置中SSL和时区问题汇总
MySQL 8.0默认开启SSL验证,有些环境如果证书有问题,启动时连接就会报SSL connection error。我见过大量同学卡在这里。解决方法是连接串里显式关闭:
spring: datasource: url: jdbc:mysql://localhost:3306/laundry?useSSL=false&serverTimezone=Asia/Shanghai&characterEncoding=utf8mb4&allowPublicKeyRetrieval=trueallowPublicKeyRetrieval=true这个参数也很关键,MySQL 8.0的客户端首次连接时会要求获取服务器的公钥来加密密码,如果连接串不带这个参数,某些环境也会报错。这三个参数,useSSL、serverTimezone、allowPublicKeyRetrieval,是我见过所有MySQL连接报错里出现频率最高的坑,一次配好能省一周的排查时间。
5.6 前后端分离部署时history路由404
前端用的是Vue Router,如果启用了history模式,打包之后部署到Nginx的静态目录,刷新某个非首页路径时,Nginx会直接404。因为Nginx尝试去找/order/list这个文件,找不到就返回404。解决方法是在Nginx配置里加一个try_files指令:
location / { try_files $uri $uri/ /index.html; }这个配置的含义是,如果请求的路径不是真实文件,就回退到index.html,交给Vue Router去自己处理路由。这个问题在前端打包放上线时才暴露,很容易被忽略,这里特意记录下来。
5.7 金额精度丢失与小数展示问题
洗衣店系统牵扯钱,金额精度特别敏感。后端我统一用BigDecimal接收和计算,前端展示金额时,如果数字是35.50,浏览器显示35.5,看起来不整齐。我在前端封装了一个formatAmount函数,用Number(amount).toFixed(2)保证始终展示两位小数。另外在订单提交时,前端计算的总金额和后端重新计算的总金额做了一次比对,如果不一致会拒绝提交,避免因为前端浮点误差导致实际入账金额错误。
6. 项目部署、打包与上线经验
6.1 后端打包的细节
SpringBoot项目打包成jar,执行mvn clean package。有几个细节要注意。一是pom.xml里要加spring-boot-maven-plugin插件,否则打出来的jar可能不是可执行jar,运行时报no main manifest attribute。二是测试代码里如果有依赖数据库的集成测试,打包时最好跳过,用mvn clean package -DskipTests,避免测试环境连不上数据库导致整个打包失败。三是数据库连接配置不要硬编码在application.yml里,用环境变量覆盖,比如:
url: ${MYSQL_URL:jdbc:mysql://localhost:3306/laundry}这样部署到不同环境,只需要在启动命令里加上--MYSQL_URL=或者设置环境变量,代码不用重新打包。
6.2 JVM启动参数和内存配置
洗衣店管理系统本身并发量不高,JVM参数不需要太多花哨的东西。我使用的启动命令是:
java -Xms256m -Xmx512m -jar laundry-server.jar --spring.profiles.active=prod内存设置256到512兆对这个系统完全够用。如果服务器内存比较紧张,Xms设成128也一样跑。有些初学者喜欢不加参数直接java -jar,默认堆内存会按机器物理内存的四分之一设置,在4G内存的机器上就可能占用1G,完全没有必要。
6.3 前端打包构建与部署路径
前端打包执行npm run build,默认输出到dist目录。这里有一个要注意的点:Vue项目的路由模式如果是history,打包后如果直接双击index.html打开,页面是白的,因为路由用的是BrowserRouter,而文件协议的url不匹配。所以前端打包后必须放在HTTP服务器里访问。我用的是Nginx,配置大概是这样:
server { listen 80; server_name laundry.example.com; root /opt/laundry-frontend/dist; 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; } }这里的location /api把前端的API请求转发到了后端jar包监听的8080端口。前后端分离的最终形态就是这样:一个静态前端,一个后端服务,靠一个反代把它们粘在一起。
6.4 数据库备份的常规方案
系统的核心资产是数据,尤其是订单和客户信息。我在服务器上写了一个简单的Shell脚本,每天晚上三点自动备份MySQL数据库,保留最近七天的备份文件:
#!/bin/bash mysqldump -uroot -p'密码' laundry > /backup/laundry_$(date +\%Y\%m\%d).sql find /backup -name "laundry_*.sql" -mtime +7 -delete再配合crontab定时任务,基本就完成了数据库的安全网。好记性不如烂笔头,定期备份这个习惯,在出问题的时候才知道有多值。
7. 个人开发心得与后续扩展方向
做这个系统最深的一个体会是:技术栈本身不是难点,真正难的是把业务流程想透。我前后改了三个版本,第一个版本表结构设计得过于复杂,订单状态搞了八个,洗完衣服还要分待打包、待质检,结果店员根本不会用,每天就点两三个按钮。后来我把状态浓缩成五个,页面按钮逻辑大幅简化,前端代码反而少了接近四成。这说明设计阶段越克制,开发阶段越轻松。
整个项目对我自己来说也是一次全栈体验的锻炼。从MySQL建库建表,到SpringBoot写接口,再到Vue3画页面,每个环节都有各自的坑,但连起来想通之后,它们之间的关系就非常清晰了。比如一个订单在后端怎么存、在数据库里长什么样、到前端页面渲染成什么样子,一条线串下来,整个系统的全貌就立体了。这种全栈视角,对以后定位问题和性能优化都非常有帮助。
还有一个小技巧值得一提:联调阶段,我会用Swagger把后端接口文档生成出来,同时在前端代码里对每个接口注释写明参数格式和返回结构,这样两个人对接时就不需要反复问。个人项目虽然只有我自己开发,但这样的好习惯仍然能防止隔段时间再看代码时忘记接口约定。写注释这件事,别偷懒,项目价值的一半都藏在代码注释和文档里。
这套系统目前已经在我朋友的店里跑了两个多月,订单管理效率确实比手工记账提升明显。后续如果再加需求,我会优先考虑两个方向。第一是增加简单的库存管理,洗衣店的洗衣耗材比如袋子、衣架、洗涤剂也可以一起管起来;第二是给客户增加一个取衣提醒短信功能,订单状态变成已完成时自动发一条短信通知客户来取衣服。这两个需求技术上都是成熟的,SpringBoot整合阿里云短信服务或者第三方短信平台都有很完善的文档,等店里经营数据再多一点,就安排上。