做计算机毕业设计最怕的不是不会写代码,而是题目选得太大或者太虚。基于SpringBoot的衣物干洗预约平台,属于业务场景清晰、技术栈成熟、工作量刚好卡在毕设节奏里的题目。这个项目从用户端下单,到门店接单,再到洗护完成回传状态,整条链路都能演示,而且SpringBoot、MyBatis-Plus、Vue这些技术都是后端岗位面试里的高频内容,做完之后简历上也能写。如果你是Java方向的应届生,或者正在为毕业设计选题发愁,这篇文章会把项目从选题、设计、编码到答辩整条线讲清楚。
这类系统的核心并不难,但很多学生在做的时候容易把边界划得太大,一上来就加支付、加优惠券、加会员积分,最后代码没写完,论文也只能赶工。正确做法是先抓一条订单主流程,把预约、接单、洗涤、完成这四个节点串起来,然后再考虑扩展。本文会结合我指导项目时的一些习惯做法,把框架选型、数据库设计、核心代码、常见坑和答辩准备都过一遍,尽量让你少走弯路。
1. 干洗预约平台的项目定位与技术选型
1.1 需求拆解:用户、门店员工、管理员这三类角色怎么分
做系统设计第一步不是建表,而是搞清楚谁会使用系统、每个角色会做什么操作。干洗预约平台最核心的角色有三种。
用户端负责注册登录、浏览服务项目、选择门店和预约时间、填写衣物信息、提交预约订单、查看订单状态、取消未开始的订单、订单完成后进行评价。门店员工端负责处理预约订单,包括接单、确认取件、将订单转为洗涤中、洗涤完成后改成待取回,以及查看自己门店下的订单列表。管理员端负责维护基础数据,比如门店信息、服务项目价格、员工账号、订单统计和系统公告。
从业务角度理解,用户提交的不是“立即支付”的购买订单,而是“预约取件”的预约单。干洗服务大多是先接单、后洗护,最后取衣时再支付,所以订单状态设计要和普通电商区分开。如果把这个流程理解成“预约单”,状态设计会自然很多。
1.2 为什么这套技术栈最适合毕业设计
SpringBoot在这个项目里的角色是后端服务框架,它帮开发者省掉了Spring MVC里大量XML配置,内嵌Tomcat,可以直接打包成jar运行。配合MyBatis-Plus操作数据库,Mapper接口写好之后,简单的增删改查甚至连SQL都不用写,分页插件也直接用。前端选择Vue + Element UI,前后端通过JSON交互,这种模式在企业里很常见,论文里也能画出清晰的前后端分离架构图。
很多学生纠结要不要用Spring Cloud。干洗预约平台的业务量还没有到微服务级别,一个单体应用完全能覆盖所有功能,硬上微服务只会让部署和演示变复杂,答辩时还容易被追问注册中心、网关、服务降级这些细节。毕业设计的目标是完整跑通业务闭环,不是堆技术名词。
版本选择上我更推荐SpringBoot 2.7.18,它是2.x系列的收尾版本,兼容JDK1.8,网上教程和依赖资料都很多。SpringBoot 3以上版本要求JDK17,包名也从javax换成了jakarta,虽然新,但对大多数学生来说迁移成本不低。如果你本机已经有JDK17,能用3.x当然更好,如果只有JDK8,千万别硬升。
1.3 数据库表设计:订单表是绝对核心,状态日志表一定要留
干洗预约平台的数据库表不需要设计得特别多,围绕业务主流程拆出七八张核心表就够了。
用户表保存用户和登录信息,包含账户、手机号、角色、昵称、创建时间。服务项目表保存干洗服务的名称、价格、预估时长,比如普通上衣、羽绒服、毛毯的清洗价格和时间都不一样。门店表保存门店名称、地址、联系电话、营业时间。衣物信息表保存用户的衣物描述,比如类型、品牌、颜色、材质备注,如果不单独建表,也可以把衣物信息冗余到订单表里。订单表是核心,订单号、用户、门店、服务项目、预约时间、状态、支付状态、金额、取消原因这些字段都不能少。订单状态日志表记录每一次状态变更,从哪个状态变成哪个状态、操作人是谁、备注是什么、什么时候变的。评价表在订单完成后生成,主要存评分和点评内容。预约时段表用于控制某个门店在不同时间段的可预约名额,防止用户扎堆。
订单表不是简单的“状态字段一改就完事”,我建议从一开始就把订单状态日志表加进去。一方面排查线上问题有据可查,另一方面论文里可以画状态流转图,答辩的时候也能说明自己对数据可追溯性有考虑。每个状态变更都在同一个事务里更新订单状态并写入日志,这是成本很低但收益很大的设计。
2. SpringBoot核心功能实现:预约、订单、权限、定时任务
2.1 预约下单:用数据库原子更新解决时段超卖
预约下单是系统里并发风险最高的接口。假设某门店某个时段只能接10单,两个用户同时提交,如果代码是“先查剩余数量,大于0就插入订单”,那么两个请求可能同时读到剩余数量为1,然后同时下单,最终这个时段实际接了11单,数据就错了。
解决办法很多,最简单可靠的是在数据库层面做原子更新。预约时段表里有一个booked字段记录已预约数量,下单时执行类似这样的SQL:
UPDATE schedule_time SET booked = booked + 1 WHERE store_id = #{storeId} AND appointment_time = #{appointmentTime} AND booked < capacity这条SQL利用数据库的行级锁保证同一时间只有一个事务能成功更新。如果影响行数是1,说明预约名额成功占用;如果影响行数是0,说明这个时段已经约满,直接提示用户更换时间。这种方案不需要引入Redis,逻辑清晰,也容易在答辩时讲明白。
订单号建议使用MyBatis-Plus自带的IdWorker,也就是雪花算法生成。订单号在数据库里要加唯一索引,哪怕并发再高也不会生成重复订单。
下单的核心Service代码可以这样组织:
@Transactional(rollbackFor = Exception.class) public Long createOrder(WashOrderCreateDto dto) { Store store = storeService.getById(dto.getStoreId()); ServiceItem item = serviceItemService.getById(dto.getServiceItemId()); if (store == null || item == null) { throw new BizException("门店或服务项目不存在"); } int rows = scheduleTimeMapper.lockTime(dto.getStoreId(), dto.getAppointmentTime(), item.getDuration()); if (rows == 0) { throw new BizException("该时段已约满,请更换预约时间"); } WashOrder order = new WashOrder(); order.setOrderNo(IdWorker.getIdStr()); order.setUserId(dto.getUserId()); order.setStoreId(dto.getStoreId()); order.setServiceItemId(item.getId()); order.setAppointmentTime(dto.getAppointmentTime()); order.setAmount(item.getPrice()); order.setStatus(OrderStatus.WAIT_CONFIRM.getCode()); order.setPayStatus(PayStatus.UNPAID.getCode()); washOrderMapper.insert(order); orderStatusLogMapper.insert(new OrderStatusLog(order.getId(), null, order.getStatus(), "用户提交预约")); return order.getId(); }注意订单状态先设置为“待接单”。用户预约之后,门店员工需要确认这个时段是否真的有条件接单,确认后订单才正式锁定。这样设计也更符合真实门店操作。
2.2 订单状态流转:状态机在干洗订单里的落地方式
订单状态不能随便改,程序里要明确“从哪个状态能变到哪个状态”。干洗预约平台的订单状态我建议这样拆:
待接单是用户刚提交预约。已接单是门店确认接收,此时可以生成取件码。洗涤中表示衣物已经进入洗护流程。待取回表示洗涤完成,等待用户到店取衣。已完成表示用户确认取走,订单结束。已取消表示未开始的订单被取消,或者超时未确认被系统自动取消。
每个状态的迁移都有前置条件。比如“待接单”只能变成“已接单”或“已取消”,不能直接跳成“洗涤中”。代码里可以用一个状态流转表或者校验方法控制。
状态变更的Service方法可以这样写:
public void changeStatus(Long orderId, Integer expectStatus, Integer targetStatus, Long operatorId) { WashOrder order = washOrderMapper.selectById(orderId); if (order == null || !order.getStatus().equals(expectStatus)) { throw new BizException("当前订单状态不允许该操作"); } order.setStatus(targetStatus); washOrderMapper.updateById(order); orderStatusLogMapper.insert(new OrderStatusLog(orderId, expectStatus, targetStatus, operatorId, "状态更新")); }用户端操作“确认取回”时,前端传过来的应该是订单号,后端先从数据库查出当前状态,判断是不是“待取回”,只有是才能改成“已完成”。如果状态不匹配,直接抛出业务异常。这种方式能避免用户或员工在页面反复点击时把状态改乱。
2.3 登录认证:用SpringBoot拦截器校验Token,不推荐硬上Spring Security
干洗预约平台包含用户、员工、管理员三个角色,登录认证是必须的。很多学生一开始就想用Spring Security,结果光是过滤链、密码加密、自定义登录页就卡了好几天。毕业设计阶段,用JWT + HandlerInterceptor做登录态管理已经足够,代码量小、逻辑清晰、答辩也能讲清楚。
JWT本身就是一段加密后的JSON字符串,服务端签发之后,前端每次请求把它放在请求头里。后端拦截器拿到Token,解析出用户ID和角色,放到ThreadLocal里,Controller和Service里直接取当前用户。
拦截器的核心逻辑如下:
@Component public class LoginInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { if (!(handler instanceof HandlerMethod)) { return true; } String token = request.getHeader("Authorization"); if (StringUtils.isBlank(token)) { throw new BizException(401, "未登录"); } Claims claims = JwtUtil.parseToken(token); Long userId = Long.valueOf(claims.get("userId").toString()); UserContext.set(userId); return true; } @Override public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) { UserContext.clear(); } }拦截器注册到WebMvcConfigurer里,同时配置放行登录、注册接口等不需要认证的路径。
Token方案相比Session的优势在于前后端分离时天然好用,不需要考虑Cookie跨域,后端服务也可以水平扩展。答辩如果问“为什么不用Session”,可以回答Session依赖服务端存储,分布式环境下多个实例之间需要共享Session,而JWT本身是无状态的,只要密钥一致就能校验。
2.4 定时清理过期订单:@Scheduled每5分钟扫一次
用户提交预约后可能一直不确认,门店的员工也不可能随时盯着后台。为了保证预约时段不浪费,系统需要自动取消超过一定时间仍未接单的订单。
用SpringBoot内置的@Scheduled就能实现。在启动类上加上@EnableScheduling,然后在定时任务类里写方法:
@Scheduled(fixedDelay = 60_000) public void cancelExpiredOrders() { List<WashOrder> list = washOrderMapper.listExpiredWaitConfirm(30); for (WashOrder order : list) { changeStatus(order.getId(), OrderStatus.WAIT_CONFIRM.getCode(), OrderStatus.CANCELLED.getCode(), SystemConstant.SYSTEM_OPERATOR); scheduleTimeMapper.release(order.getStoreId(), order.getAppointmentTime()); } }这里fixedDelay表示上一次任务执行完再等60秒,和cron定时表达式相比,fixedDelay更适合任务执行时间不固定的场景。查询超时订单时写一个带条件的SQL,比如“状态为待接单且创建时间小于当前时间减去30分钟”,每次批量处理100条,避免一次性加载太多数据。
还需要注意一点:释放预约时段的时候,要让schedule_time表的booked字段减1。否则用户取消或超时取消后,时段名额不会恢复,系统越用越拥堵。
3. 联调阶段最容易踩的四个SpringBoot坑
3.1 SpringBoot版本太高导致JDK8项目跑不起来
有学生下载了SpringBoot最新的3.x版本,然后在Java8环境下创建项目,一启动就报错,提示类文件版本不支持。这不是代码问题,是版本兼容问题。SpringBoot 3.0之后强制要求JDK17,并且原来javax开头的包全部变成了jakarta开头,比如javax.servlet变成了jakarta.servlet。网上很多SpringBoot 2.x的教程直接拿来用,会出现import报错。
如果本机环境是JDK8,就老老实实锁版本。可以在pom.xml里加:
<parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>2.7.18</version> <relativePath/> </parent>使用SpringBoot 2.7.18配合JDK8,几乎所有常用依赖都能找到现成版本,MyBatis-Plus、JWT、Redis、FastJSON这些生态也完全兼容。
3.2 MyBatis-Plus分页插件总是没效果
分页是管理后台列表页面最常用的功能。MyBatis-Plus使用分页时要配置内置拦截器,如果没配置,执行selectPage时虽然返回了Page对象,但SQL里不会拼接LIMIT,最后的查询结果是全表数据,前端分页自然就乱了。
MyBatis-Plus 3.5.x版本的正确配置是:
@Configuration public class MybatisPlusConfig { @Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor(); PaginationInnerInterceptor pagination = new PaginationInnerInterceptor(DbType.MYSQL); pagination.setMaxLimit(500L); interceptor.addInnerInterceptor(pagination); return interceptor; } }注意类名是PaginationInnerInterceptor,不是旧版的PaginationInterceptor。如果用了旧包名,项目可能直接启动失败,也可能运行时不生效。配置好之后,再调用Mapper的selectPage方法,控制台日志里能看到自动拼接的LIMIT语句。
3.3 前端跨域导致登录接口在浏览器里被拦截
前后端分离开发时,前端跑在http://localhost:8080,后端跑在http://localhost:9090,两个端口不同就属于跨域。如果后端不做处理,浏览器会先发出OPTIONS预检请求,接口如果没正确响应,前端就看不到真正的业务数据。
推荐在后端统一定义CorsFilter,而不是在每个Controller上写@CrossOrigin。一个全局过滤器就够了:
@Configuration public class CorsConfig { @Bean public CorsFilter corsFilter() { CorsConfiguration config = new CorsConfiguration(); config.addAllowedOriginPattern("*"); config.addAllowedHeader("*"); config.addAllowedMethod("*"); config.setAllowCredentials(true); UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); source.registerCorsConfiguration("/**", config); return new CorsFilter(source); } }使用Token认证时,前端要把JWT放在Authorization请求头里,所以后端允许的请求头必须包含所有自定义请求头。如果前端开启了withCredentials,后端不能使用addAllowedOrigin(""),而要使用addAllowedOriginPattern(""),否则浏览器会拦截。
3.4 并发操作导致订单状态被覆盖
后台员工可能同时打开两个浏览器标签页处理同一个订单,或者在订单状态已经变成“已取消”之后,旧页面仍提交“接单”请求。如果不加条件,后一次的updateById会把状态覆盖掉,造成状态回退。
这就需要在更新订单状态时带上前置状态条件。可以定义一个专门的方法:
int rows = washOrderMapper.updateStatusWithExpect(orderId, expectStatus, targetStatus); if (rows == 0) { throw new BizException("订单状态已变更,请刷新后重试"); }对应的Mapper SQL类似:
UPDATE wash_order SET status = #{targetStatus} WHERE id = #{orderId} AND status = #{expectStatus}只有当前状态确实是预期状态时,更新才成功。这个思路和乐观锁的CAS理念一样,数据库行级锁保证并发安全,代码实现也很简单。
4. 测试、演示数据与答辩准备
4.1 用Postman把核心接口过一遍,整理成接口测试清单
系统写完之后不要急着截图写论文,先用Postman把所有接口按业务流程跑一遍。接口测试清单可以按模块整理成表格。
用户模块需要测试注册、登录、获取当前用户信息三个接口。服务模块测试查询服务项目列表、查看门店列表。预约模块测试提交预约、取消预约、查看我的订单列表、查询预约时段。员工模块测试接单、更新洗涤状态、完成订单。评价模块测试提交评价和查看评价列表。管理模块测试用户管理、门店管理、订单统计接口。
每个接口要记录请求方式、请求路径、关键参数、成功返回和失败返回。比如提交预约接口,成功时返回订单号;失败时可能返回“该时段已约满”。Postman里的每个用例保存好,后续写测试报告直接复用,论文的“系统测试”章节就能填上内容。
4.2 演示数据要提前准备好,现场Demo不能现造
答辩现场最怕的是登录之后发现服务项目为空、订单列表一片空白。准备演示数据是有技巧的。门店数据准备两三家,服务项目准备五六个,价格和预估时间要符合常识,比如普通T恤清洗10元、羽绒服清洗45元、窗帘清洗80元。用户账号准备两个,一个普通用户,一个管理账号;员工账号至少一个,分配给某个门店。
演示路径也应该提前走一遍。先用用户账号登录,提交一个新订单,选好门店和预约时间;然后切到员工账号登录,看到这个订单并接单,再把状态改成洗涤中;接着改成待取回;最后切回用户账号,确认取回订单,提交一条评价。整个过程在数据库里会产生一条完整的订单状态日志,答辩时直接展示状态日志表,比空口讲设计有说服力得多。
4.3 答辩高频追问怎么答:要能说清每一个“为什么”
答辩老师一般不会只问“这个系统有哪些功能”,他们更关注设计理由和异常处理思路。
问为什么选SpringBoot,可以从配置简化、内嵌服务器、生态成熟三个角度回答。问下单并发怎么处理,直接说预约时段表更新的原子操作,把SQL写出来更稳妥。问订单状态如何在并发情况下不混乱,把状态校验、状态日志表、乐观更新方案讲清楚。问为什么用JWT不用Session,就围绕前后端分离和无状态认证展开。问定时任务如果多个实例部署会不会重复执行,可以承认单体部署没这个问题,如果分布式部署需要引入分布式锁,比如Redis的setnx命令。
答辩的核心思路是讲取舍。每个技术方案都有适用场景,不要吹得天花乱坠,把当前项目为什么选这个方案讲清楚,老师自然认可。
5. 还能往哪些方向加亮点
5.1 从毕设上升到工程化的几个扩展方向
如果核心流程已经跑通,还有时间和精力,可以从工程化角度增加一两个亮点。
引入Redis缓存服务项目列表和门店列表,可以减少数据库查询压力,也能顺便讲缓存穿透、缓存更新这些面试常问点。引入WebSocket推送订单状态,用户不需要反复刷新页面就能看到洗涤完成消息。接入消息队列,比如RocketMQ或RabbitMQ,把“订单完成”事件异步通知给用户,这种设计在真实的系统里也非常常见。接入第三方支付沙箱,用户在线支付预约费用,系统会更完整。
需要注意的是,扩展功能不要贪多,挑一个方向做到能演示、能讲清楚,比堆三个半成品强得多。我自己比较推荐Redis缓存或WebSocket推送,这两个方向在面试和论文里的表达效果都很好。
5.2 安全优化:XSS过滤和接口防刷
很多学生在项目里完全没考虑安全,但这其实是个很加分的点。全局过滤器可以统一处理XSS攻击,例如把请求参数里的