
校园洗衣机预约这种事凡是住过宿舍的人多少都有过切肤之痛。洗一次衣服要跑到洗衣房看有没有空机运气不好碰上高峰期还得排队等更别提有人把衣服放进去人却不知道跑哪去了洗衣机空转半天。作为一个做过不少Java后端项目的开发者我当年选毕设题目时第一眼看到这个基于SpringBootVue的高校智能洗衣预约平台就知道这是个特别经典的选题——技术栈主流、业务闭环完整、有真实并发场景可挖从毕业设计到实际落地都有足够的想象空间。这篇内容我会从选题拆解、技术方案、核心业务实现到踩坑实录完整讲一遍适合正在做Java方向毕设/课设的同学也想给想快速上手SpringBootVue全栈开发的初级工程师一些可直接复用的思路。文中涉及代码和表结构我都会给到可运行的版本设计取舍背后的原因也会一并讲清——毕竟答辩时老师最爱问的就是为什么。1. 项目整体设计与方案选型1.1 为什么选SpringBootVue而不是SSH或者JSP直出选技术栈这件事很多人上来就纠结哪个框架新但在毕设场景里更应该想清楚哪个组合能让我把业务完整做完、答辩时讲得清。SpringBoot在Java Web领域已经是事实标准它的自动配置把SpringMVC、MyBatis、事务管理这些东西粘合得非常顺滑内嵌Tomcat也让部署变成一个jar包扔上去就跑。对比十年前流行的SSHStrutsSpringHibernateSpringBoot的项目结构简洁得多没有一堆繁琐的XML配置这对需要在一学期内完成系统的学生来说极其友好。前端用Vue的理由则更贴近实际使用场景。如果还像以前那样用Thymeleaf或者JSP做服务端渲染洗衣机设备列表的刷新、预约时间的实时联动、管理后台的数据展示都会变得很别扭。Vue的核心是组件化和响应式设备卡片、时间选择器、订单列表这些东西天然适合拆成独立组件页面之间的状态同步也省心。再加上Element UI或者Ant Design Vue这类现成的组件库后台界面几乎不需要花时间在CSS上。还有一个关键因素这套组合在就业市场上足够通用。SpringBootVue算是Java全栈开发的主流搭配做完这个项目写进简历面试官看到的就是熟悉主流开发框架具备前后端分离项目经验比写熟悉JSPServlet要有说服力得多。提示如果你所在学校对论文里创新点有硬性要求可以在基础CRUD上叠加两个亮点比如预约调度算法、数据可视化统计后面5.4节会展开讲。技术上不必上微服务、消息队列这些重武器单体架构把业务做扎实才是毕设的正道。1.2 系统架构与角色权限设计整个系统按前后端分离的思路拆分后端只提供RESTful API前端负责页面渲染和交互。部署形态是SpringBoot打包成jar、Vue构建后的静态资源由Nginx托管也可以直接扔进SpringBoot的static目录后面会讲部署的坑。数据库选用MySQL 8.0连接池用Druid或者HikariCP都行SpringBoot2.x默认HikariCP性能足够也省心。从角色维度看系统至少要拆成三种身份普通学生、洗衣房管理员、系统超级管理员。学生端的核心诉求是找机器、预约、付款、查状态管理员的核心诉求是管设备、看订单、处理故障再往上一层是管用户、配参数、看报表。这套权限模型用Spring SecurityJWT实现很成熟登录后前端拿到token存在本地每次请求在拦截器里校验角色接口层面用PreAuthorize控制访问权限。模块划分上我建议拆成五块用户认证模块、设备管理模块、预约订单模块、支付/结算模块、统计报表模块。每一块职责单一写论文时的功能模块图也好画后面扩展也不会牵一发动全身。这里多说一句我当时最遗憾的就是在调度这个词上想浅了——标题里说的是共享洗衣机调度系统调度不是简单的列出空闲设备而是要处理某时段某设备能不能约的冲突判断这个会在2.3节专门讲。2. 核心业务模块与数据模型设计2.1 设备管理与状态流转洗衣机设备不是一张表加几个字段就完事的它背后有一套状态流转逻辑。我把设备表设计成device核心字段包括设备编号、所属楼栋、楼层、具体位置比如3号楼2层洗衣房A区03号机、品牌型号、洗衣模式JSON数组存该设备支持的模式和对应价格、当前状态、最后维护时间。设备状态我设定为五种空闲、已预约、运行中、故障、维护中。为什么把已预约和运行中分开因为你预约了一台机器但它还没开始洗这时候别人不应该能约同一个时间段。而运行中是洗衣已经开始此时设备被占用是物理上不可逆的。故障和维护的区别在于故障是突发异常维护是计划性的暂停。这个状态机在前后端都要有一份约定前端页面根据状态显示不同样式后端接口根据状态决定允许哪些操作。状态流转的规则要写清楚空闲-已预约用户提交预约且锁定位已预约-运行中用户到现场扫码确认开始运行中-空闲洗衣完成空闲/已预约-故障管理员标记故障-维护中-空闲维修完成后。这些流转在Service层用switch-case或者状态机模式实现每个分支的触发条件和前置校验写在一起会比散落的if判断清晰得多。2.2 预约订单与状态机设计预约是整个系统的核心业务。一张预约订单reservation需要记录订单编号业务上最好用时间戳随机数的短编号方便客服口头核对、用户ID、设备ID、预约开始时间和结束时间、洗衣模式、订单状态、实际开始时间、实际结束时间、支付状态、支付单号、取消时间/原因。订单状态我用枚举定义PENDING待使用、USING使用中、COMPLETED已完成、CANCELLED用户取消、EXPIRED超时未使用、REFUNDING退款中。这里最容易被忽略的是EXPIRED状态——用户预约了但放鸽子设备空等对其他同学不公平。解决思路是预约成功后给15分钟宽限期用户必须在预约开始时间前后15分钟内签到确认使用超时自动释放设备并标记订单为EXPIRED。这个逻辑用SpringBoot的Scheduled定时任务就能实现每30秒扫描一次到期未签到订单。支付状态单独拆出来挂到订单上不要和订单状态混在一起。因为可能出现已支付但超时未用需要退款、先预约后支付的时序问题。哪怕毕设阶段用模拟支付也建议把支付记录表payment单独设计出来字段包括支付单号、订单号、支付渠道、金额、支付时间、回调状态。这样后面接微信支付或者支付宝沙箱就是加一个渠道适配的事不用改表结构。2.3 数据表设计与预约冲突检测SQL表结构这块直接上核心脚本用户表、设备表、订单表三张最重要。user表简单但要加student_no学号做业务唯一索引预约时要用。device表注意laundry_modes字段用JSON存避免一张模式表搞出复杂的关联查询。reservation表是重中之重索引设计决定了预约冲突检测的性能。CREATE TABLE user ( id bigint NOT NULL AUTO_INCREMENT, username varchar(64) NOT NULL COMMENT 登录账号, password varchar(128) NOT NULL COMMENT BCrypt加密后密码, student_no varchar(32) DEFAULT NULL COMMENT 学号, real_name varchar(32) DEFAULT NULL COMMENT 姓名, phone varchar(20) DEFAULT NULL, role tinyint NOT NULL DEFAULT 1 COMMENT 1学生 2管理员 3超管, status tinyint NOT NULL DEFAULT 1 COMMENT 1正常 0禁用, PRIMARY KEY (id), UNIQUE KEY uk_username (username), UNIQUE KEY uk_student_no (student_no) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE device ( id bigint NOT NULL AUTO_INCREMENT, device_no varchar(32) NOT NULL COMMENT 设备编号, building varchar(64) DEFAULT NULL COMMENT 楼栋, floor_no int DEFAULT NULL, location_desc varchar(128) DEFAULT NULL COMMENT 放置位置描述, laundry_modes json DEFAULT NULL COMMENT [{mode:standard,name:标准洗,price:3.0,duration:45}], status tinyint NOT NULL DEFAULT 0 COMMENT 0空闲 1已预约 2运行中 3故障 4维护, last_maintain_time datetime DEFAULT NULL, PRIMARY KEY (id), UNIQUE KEY uk_device_no (device_no), KEY idx_building_floor (building, floor_no) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE reservation ( id bigint NOT NULL AUTO_INCREMENT, order_no varchar(32) NOT NULL, user_id bigint NOT NULL, device_id bigint NOT NULL, start_time datetime NOT NULL, end_time datetime NOT NULL, status tinyint NOT NULL DEFAULT 0, pay_status tinyint NOT NULL DEFAULT 0 COMMENT 0未支付 1已支付 2已退款, mode_code varchar(32) DEFAULT NULL COMMENT 洗衣模式编码, actual_start_time datetime DEFAULT NULL, actual_end_time datetime DEFAULT NULL, cancel_reason varchar(255) DEFAULT NULL, create_time datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_order_no (order_no), KEY idx_user_id (user_id), KEY idx_device_time (device_id, start_time, end_time) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;预约冲突检测的SQL是整个系统的技术核心。用户选定设备、时间段后后端要判断该时间段内这台设备是否已被其他订单占用。冲突的本质是区间重叠新预约区间[a,b]和已有订单区间[c,d]重叠当且仅当a d 且 c b。对应SQL就是查同样设备在相同状态未取消、未过期的订单中是否存在start_time 新结束时间 且 end_time 新开始时间的记录。SELECT COUNT(*) FROM reservation WHERE device_id #{deviceId} AND status IN (0, 1) -- 待使用或使用中 AND start_time #{endTime} AND end_time #{startTime}这条SQL写出来不难但要保证它在并发下不超卖就需要配合事务和锁来处理3.1节会专门讲实现方案。加钱补一句索引idx_device_time就是为这个查询设计的查询条件里device_id走组合索引前缀start_time和end_time作为范围条件也能利用索引数据量到了几千条性能依然没问题。3. 关键功能实现与实操细节3.1 预约并发冲突的解决方案先抛出问题的现象两个学生同时盯着同一台洗衣机、同一个时间段比如晚上8点到9点几乎同时点了预约如果代码只是先SELECT校验再INSERT两个请求都可能校验通过于是出现重复预约。这在真实的毕设答辩现场也是老师最爱追问的点。解决办法有几种从重到轻排列一是数据库层面用SELECT ... FOR UPDATE预约事务开始前锁定对应设备记录二是乐观锁给device表加version字段UPDATE时检查version是否变化三是用Redis分布式锁做互斥。我建议毕设用第一种理由很务实单机MySQL就能实现不需要额外依赖Redis而且FOR UPDATE锁的是设备ID对应的行锁粒度正好是同一台设备不同设备之间完全不影响。具体写法是Service层开事务先执行SELECT id FROM device WHERE id #{deviceId} FOR UPDATE拿到设备行的排他锁再做冲突检测、插入预约订单、更新设备状态为已预约最后提交事务。由于同一设备的并发请求会串行等锁第二个事务在第一个提交后才执行此时再检测冲突就一定能看到前面插入的记录超卖从根上被堵死了。用生活的类比就是银行柜台叫号同一位柜员不可能同时办两个业务后到的听到号就得等着。注意FOR UPDATE必须在事务内才生效而且检测冲突和插入订单的SQL一定要放在同一个事务方法里。另外别偷懒给所有查询都加FOR UPDATE那样会锁表性能会很难看只给预约-占用设备这种写操作加就行。3.2 后端接口设计与JWT权限控制接口这块按照REST风格来规划。用户端常用的有POST /api/auth/login 登录返回tokenGET /api/device/list 设备列表支持楼栋筛选、状态筛选GET /api/device/{id} 设备详情含可预约时间段POST /api/reservation 创建预约POST /api/reservation/{id}/cancel 取消预约开始前30分钟允许POST /api/reservation/{id}/signin 签到确认开始使用GET /api/reservation/my 我的预约列表GET /api/order/my 我的订单列表结合支付状态管理端接口加一层权限校验用PreAuthorize(hasRole(ADMIN))标注普通学生调用直接返回403。Spring Security的配置要点是公开接口放行登录、注册、设备列表其他接口统一走JWT过滤器。JWT生成用jjwt库密钥放到application.yml里token有效期建议设2小时过期后前端拿401就跳登录页重新登录。一个细节是角色权限用超管和普通管理员两级。超管能配置洗衣模式价格、查看全局统计普通管理员只能处理设备故障、查看本楼栋订单。用Spring Security自带角色继承role hierarchy就能实现不用复杂权限模型。3.3 定时任务处理超时未签到订单Scheduled是SpringBoot内置的定时任务注解做这个场景再合适不过。我在一个ScheduledTask类里配置两个任务一个是每分钟扫描一次预约开始时间已超过15分钟但仍处于PENDING状态、且未签到的订单将其状态改为EXPIRED并释放设备如果该订单已支付则触发退款流程另一个是每分钟扫描一次运行中且实际结束时间已到的订单将其置为COMPLETED并把设备改回空闲。Component public class ReservationTask { Scheduled(fixedDelay 30000) Transactional(rollbackFor Exception.class) public void handleExpiredReservations() { ListReservation expiredList reservationMapper.selectExpiredPending( LocalDateTime.now().minusMinutes(15)); for (Reservation r : expiredList) { r.setStatus(ReservationStatus.EXPIRED.getCode()); reservationMapper.updateById(r); deviceMapper.releaseDevice(r.getDeviceId()); // 如果已支付调用退款逻辑 } } }有一个容易踩的坑是fixedDelay和cron的区别。fixedDelay表示上一次执行完等30秒再执行下一次适用于任务本身耗时不固定但不想重叠的场景cron表达式适合固定周期。毕设里推荐fixedDelay因为如果某次扫描因为数据库锁等待耗时久了fixedDelay能保证任务不会出现上一次还没结束下一次又开始的并发问题。另外别忘了在启动类或配置类上加EnableScheduling注解这个漏掉的话任务永远不会触发排查起来还挺隐蔽。3.4 前端Vue实现要点与环境配置前端我是用Vue3 Vite Element Plus搭的。Vite比Webpack在开发体验上舒服启动快、热更新跟手。项目基础结构按views页面、components组件、api接口封装、router路由、storePinia状态管理划分。路由设计比较简单直接/login登录页、/home设备列表页、/reservation/my我的预约、/admin设备管理、/admin/orders订单管理、/admin/stats统计页。需要权限的页面通过路由守卫判断token和用户角色管理端路由额外校验role。Axios封装是前端的一个重点。我在api/request.js里配置baseURL、请求拦截器自动带token、响应拦截器401统一跳登录、403提示无权限、其他错误码用Element Plus的Message组件提示。这样做的好处是所有接口的错误处理都收敛在一个文件里新增页面时不用重复写try-catch。一个很重要的细节是响应拦截器里不能直接用router.push跳转因为拦截器模块和路由模块可能存在循环依赖建议用window.location.href或者通过事件总线通知。时间选择这块建议用Element Plus的DatePicker组件配合disabled-date属性。用户选预约时间段时把该设备已有的预约区间传到底层disabled-date里判断当天是否存在冲突。这个交互体验非常直观能选的时段高亮不能选的置灰用户不用试错。实现上要关注的是DatePicker返回值是Date对象而接口要的是yyyy-MM-dd HH:mm:ss格式必须在提交前用dayjs格式化一次否则后端LocalDateTime解析可能报错。4. 常见问题与排查技巧实录4.1 并发预约超卖问题的实际排查过程这个问题的现象很典型压测或者多人同时操作时同一台设备的同一时段出现了两条PENDING状态的预约记录。排查步骤是这样走的第一步看SQL日志确认两个请求都执行了冲突检测SELECT且都返回0条冲突第二步看事务日志确认两个请求的事务确实是并发执行没有互相等待第三步检查代码发现冲突检测和INSERT之间没有任何锁机制属于典型的check-then-act竞态条件。解决方案上文中3.1节已经写了FOR UPDATE这里补充一个细节FOR UPDATE的锁要加在一条必然能命中索引的查询上。如果你写SELECT * FROM device WHERE device_no #{deviceNo} FOR UPDATE而device_no是唯一索引没问题。但如果你用普通的ID查询条件且表里恰好没有主键这不太可能但有些同学建表确实会忘掉主键MySQL会退化成锁全表。正确的做法就是锁device表的主键记录一次性锁定这一台设备的预约资格后面的校验和插入就安全了。4.2 时间冲突查询慢与索引失效预约量上来之后有些同学会发现冲突检测SQL在慢查询日志里频繁出现。用EXPLAIN看执行计划常见问题是typeALL全表扫描或者key为NULL。原因不外乎两种没建组合索引、或者查询条件里用了函数导致索引失效。比如有人在SQL里写WHERE device_id ? AND DATE_FORMAT(start_time, %Y-%m-%d) ?DATE_FORMAT包住start_time之后索引就用不上了应该改成范围条件start_time ? AND start_time ?。索引选择上我用的是idx_device_time(device_id, start_time, end_time)。这里要特别注意顺序等值条件的device_id放前面范围条件的start_time和end_time放后面这是B树索引最友好的形态。现实中我见过反着建的idx_start_time_device_id结果冲突检测走了两个索引再交集性能反而不如一个组合索引。4.3 Vue打包后部署到SpringBoot的几个坑有些同学图省事不单独部署Nginx直接把Vue构建产物复制到SpringBoot的src/main/resources/static目录下让后端统一提供访问。这个方案可行但坑不少我一个个说。第一个坑是路由模式。Vue Router默认是hash模式URL里带#扔到static下虽然能跑但看起来不专业改用history模式后页面路径变干净了可是用户直接访问/order/list这种深层路由时SpringBoot没有对应Controller就会返回404。解决办法是加一个Controller把所有非API路径转发到index.html或者前端干脆保留hash模式毕设演示用hash模式完全够。第二个坑是favicon。复制过去之后刷新浏览器标签页可能404虽然不影响功能但答辩时很刺眼。把favicon.ico放到static根目录下同时清理浏览器缓存基本能解决。第三个坑是API地址。前端axios的baseURL如果写死成http://localhost:8080部署到服务器后就得改代码重新打包。规范的做法是baseURL留空或者用相对路径/api通过Nginx反向代理转发到SpringBoot如果不加Nginx就在SpringBoot里把静态资源和API都放在同一个端口前端请求统一走相对路径。这个细节看似简单却是很多同学部署时卡壳最多的地方。4.4 定时任务不执行或重复执行Scheduled不执行九成是忘了EnableScheduling剩下那一成是任务方法被Spring AOP切面拦掉导致代理失效。排查时先在方法入口打日志确认Spring容器启动时有没有扫描到这个Bean。重复执行则通常发生在集群部署场景两个后端实例同时跑定时任务导致同一批订单被处理两次。毕设一般单机部署不太会遇到。但如果你后面想扩展可以在定时任务里加一个简单的Redis锁SET key value NX EX 60拿到锁的实例才执行。这个成本不高写进论文里也算一个加分的扩展点。5. 从毕设到可落地系统的扩展思路5.1 与硬件的联动当前系统的签到开始使用是人工在App/小程序里点按钮完成的真实场景里应该和洗衣机硬件联动。常见的联动方式有两种一是扫码洗衣机上贴二维码学生扫码后触发设备控制板的API由控制板解锁启动同时把状态回传给服务器二是物联网MQTT上报洗衣机内置WiFi模块洗衣开始、结束、故障都通过MQTT发布消息后端订阅后更新订单和设备状态。毕设阶段不需要真硬件但要留好接口抽象。比如定义DeviceClient接口提供start(deviceId, mode)和notifyFinished(deviceId)等方法模拟实现是直接改数据库状态硬件实现是HTTP调用硬件网关。写论文时讲清楚这套接口隔离的设计思想老师会觉得你的系统具备真实工程落地意识。5.2 支付模块从模拟到真实毕设里的支付一定要用模拟支付不然各种资质和回调会把人耗死。模拟支付思路是预约时生成支付单号前端展示一个确认支付按钮点完直接调后端标记已支付。但表结构要按真实支付来设计尤其是回调这两个字要理解到位真实支付中用户付完钱用户端看不到支付结果是支付平台异步回调你的服务器告诉你这笔单支付成功了。所以后端必须留一个receieveCallback接口来处理异步通知这就是表里pay_status和callback_status两个字段分开的原因。把模拟支付切换成微信支付沙箱或者支付宝沙箱并不难难点只在签名的计算和回调验签。毕业论文如果打算写接入真实支付渠道建议至少把沙箱流程跑通答辩演示比口头描述有说服力得多。5.3 消息提醒与数据统计消息通知这个模块很多人会忽略但它对用户体验的提升非常明显。预约成功要通知、临开始前要提醒、洗衣完成要告知取衣这三个节点如果用简单的我的消息站内信实现成本很低就是个notice表加一个查询接口的事但却能让系统完成度上一个档次。统计报表适合用ECharts画几张大屏图表按楼栋统计预约量、按时间段统计设备利用率、按洗衣模式统计收入。这些统计SQL都不复杂核心是GROUP BY和日期函数。注意设备利用率是运行时长/可用时长分母要扣除维护时间这个口径在论文里写明免得答辩时被问得答不上来。5.4 可预约时间段生成的调度策略很多实现在预约时让用户随意选任意时间段但真实的共享设备需要槽位的概念。比如把一天切成48个半小时槽位每个槽位对应某台设备的一个可预约区间用户只能选空闲槽位。这样做的好处是其一冲突检测从区间重叠变成了槽位是否被占用本质上更简单其二方便运维设置晚间10点后不可预约这类规则其三调度系统可以基于槽位做容量预测提前调配设备。我在这个扩展点上吃过亏一开始直接让用户自定义开始和结束时间结果有人预约凌晨三点洗衣服虽然技术上没毛病但实际场景完全不合理。加上槽位之后很多边界问题直接被数据结构消化掉了。从整个项目复盘来看这个题目最值得做的不是那些花哨功能而是把预约的并发控制、状态机的严谨流转、时间冲突的高效检索这三件事想透、做稳。这三件事覆盖了后端开发的几个核心能力事务与锁、状态设计、索引优化恰好是面试里最容易考察候选人的点。如果你正为毕设选题发愁这个方向可以认真考虑一下——技术上有成长空间业务场景真实做出来的东西也确实能让人愿意用。