SpringBoot外卖系统实战:MyBatis-Plus+Redis生产级骨架

SpringBoot外卖系统实战:MyBatis-Plus+Redis生产级骨架 简介本资源是一份面向计算机专业本科生及Java初学者的毕业设计文档完整呈现了基于SpringBoot的外卖点餐管理系统从需求分析到系统实现的全过程。文档涵盖引言、系统分析含技术/运行/经济可行性、总体设计登录、员工、分类、菜品、套餐、订单六大模块、数据库设计及各模块具体实现细节并附有摘要、关键词、参考文献与致谢结构规范符合高校毕业论文写作标准。资源为单个DOCX文件大小1.03MB内容详实目录清晰含16页正文与完整技术栈说明SpringBootMySQLVueElement-UIMyBatisPlusRedis。目前已有86人学习下载读者可直接复用该文档作为课程设计参考、毕设开题与写作范本或结合代码工程快速理解前后端分离架构在餐饮业务场景中的落地逻辑与模块划分方法。1. SpringBoot外卖点餐管理系统不是Demo是能跑通的生产级骨架这不是一个“Hello World”式的教学项目而是一套完整覆盖商家后台、订单流转与缓存加速的真实业务骨架。它解决的不是“能不能跑”而是“上线后扛不扛得住”——比如高峰期员工批量启售菜品时页面卡顿、Redis缓存穿透导致MySQL被刷崩、Vue前端提交带口味组合的菜品数据后后端解析失败等实际问题。系统采用SpringBoot 2.7.x非3.x MyBatis-Plus 3.5.x Vue 2.6 Element-UI 2.15 的稳定组合避开了SpringBoot 3.x对JDK17的强制依赖和Vue3的Composition API迁移成本适合毕业设计快速交付也经得起中小餐馆日均500单的压测。如果你正在写Java毕设、需要可部署的源码、或想拆解一个真实外卖后台的分层结构——这个项目不是参考答案而是你本地mvn clean install后就能登录管理后台、添加菜品、生成订单的最小可行产品MVP。2. 技术选型落地为什么用MyBatis-Plus而不是JPA以及Redis缓存策略怎么防穿透2.1 ORM层选型MyBatis-Plus的三层封装如何支撑快速迭代SpringBoot项目中ORM框架选型直接影响开发效率与SQL可控性。本系统放弃JPA的全自动映射选择MyBatis-Plus核心原因有三点一是菜品口味这类多对一关系一个菜品可有多个口味选项在JPA中需配置复杂的一对多双向关联而MyBatis-Plus通过TableField(exist false)配合手动SQL更灵活二是订单状态机待付款→待派送→已完成需频繁更新特定字段MyBatis-Plus的LambdaUpdateWrapper可精准指定set字段避免JPA全量更新带来的脏数据风险三是分页场景下MyBatis-Plus的PageT对象直接集成IPage接口与前端Element-UI的el-table分页参数天然匹配无需额外DTO转换。提示MyBatis-Plus的TableName(dish)必须显式声明因MySQL表名dish与Java类名Dish大小写不一致Linux服务器默认区分否则启动报Table db.dish doesnt exist。具体实现上系统采用三层封装Entity层Dish.java仅定义数据库字段TableLogic标注逻辑删除字段is_deletedDTO层DishDto.java继承Dish新增ListDishFlavor flavors属性用于接收前端口味JSON数组VO层DishVO.java仅含前端需要的字段如categoryName避免敏感字段如create_user泄露。关键代码如下// DishController.java PostMapping(/dish) public RString save(RequestBody DishDto dishDto) { // 1. 保存菜品主信息 dishService.save(dishDto); // 2. 保存口味关联批量插入 ListDishFlavor flavors dishDto.getFlavors(); if (flavors ! null !flavors.isEmpty()) { flavors.forEach(f - f.setDishId(dishDto.getId())); dishFlavorService.saveBatch(flavors); } return R.success(新增菜品成功); }此处RequestBody DishDto自动完成JSON到对象的反序列化但需注意DishFlavor中的value字段为JSON字符串如[微辣,免葱]MyBatis-Plus默认不支持嵌套JSON存入TEXT字段需在DishFlavorMapper.xml中自定义resultMap映射!-- DishFlavorMapper.xml -- resultMap idBaseResultMap typecom.sky.entity.DishFlavor id propertyid columnid/ result propertydishId columndish_id/ result propertyname columnname/ result propertyvalue columnvalue javaTypestring/ /resultMap2.2 Redis缓存设计分类/菜品列表缓存与穿透防护双机制系统将高频读、低频写的分类数据菜品分类、套餐分类和菜品列表放入Redis但未简单使用StringRedisTemplate.opsForValue().set()而是构建了两级缓存策略2.2.1 缓存键设计避免Key冲突与过期雪崩分类缓存Keycategory:11表示菜品分类、category:22表示套餐分类菜品缓存Keydish:category:{categoryId}如dish:category:1过期时间分类缓存设为24小时TimeUnit.HOURS菜品缓存设为2小时TimeUnit.HOURS并启用随机偏移量±10分钟防止大量Key同时过期。2.2.2 缓存穿透防护布隆过滤器空值缓存当用户查询不存在的分类ID如/category/list?type999时若直接查DB返回null攻击者可构造大量非法ID打穿缓存。系统采用“空值缓存”兜底// CategoryServiceImpl.java Override public PageCategory page(PageCategory page, LambdaQueryWrapperCategory wrapper) { // 1. 先查Redis String cacheKey category: wrapper.getEq().get(type); Object cacheObj redisTemplate.opsForValue().get(cacheKey); if (cacheObj ! null) { return (PageCategory) cacheObj; } // 2. 查DB若为空则写入空值缓存5分钟 PageCategory dbPage categoryMapper.selectPage(page, wrapper); if (dbPage.getRecords().isEmpty()) { redisTemplate.opsForValue().set(cacheKey, new Page(), 5, TimeUnit.MINUTES); } else { redisTemplate.opsForValue().set(cacheKey, dbPage, 24, TimeUnit.HOURS); } return dbPage; }注意空值缓存时间必须显著短于正常缓存5分钟 vs 24小时否则会导致合法数据长期无法更新。2.2.3 缓存一致性更新时主动失效而非等待过期当管理员修改菜品分类名称时系统不依赖TTL自动过期而是主动删除相关缓存// CategoryServiceImpl.java Override public void update(Category category) { categoryMapper.updateById(category); // 主动删除分类缓存key为category:1或category:2 String cacheKey category: category.getType(); redisTemplate.delete(cacheKey); // 同时删除该分类下的菜品缓存key为dish:category:{categoryId} redisTemplate.delete(dish:category: category.getId()); }此策略牺牲少量写性能换取读一致性避免用户看到“分类已改但菜品列表仍显示旧分类名”的体验断层。3. 前后端联调关键点Vue Element-UI表格分页与SpringBoot Page参数对齐3.1 Element-UI分页组件与MyBatis-Plus Page的参数映射Vue前端使用el-pagination组件其current-page当前页码和page-size每页条数需精确传递给后端PageT对象。常见错误是前端传currentPage1后端却用page.setCurrent(1)——这会导致第1页数据从数据库第1条开始取但MyBatis-Plus的Page默认current1对应第0条必须显式设置size// dish.vue handleSizeChange(val) { this.queryParams.pageSize val; // 每页条数 this.fetchData(); // 重新请求 }, handleCurrentChange(val) { this.queryParams.pageNum val; // 当前页码 this.fetchData(); }, fetchData() { // Axios请求参数 const params { page: this.queryParams.pageNum, pageSize: this.queryParams.pageSize, name: this.queryParams.name // 搜索关键词 }; listDish(params).then(response { this.tableData response.data.records; // records是Page对象的列表字段 this.total response.data.total; // total是总记录数 }); }后端Controller必须接收page和pageSize并构建PageDish// DishController.java GetMapping(/list) public RPageDish list( RequestParam(defaultValue 1) Integer page, RequestParam(defaultValue 10) Integer pageSize, RequestParam(required false) String name) { PageDish dishPage new Page(page, pageSize); // 关键page1表示第1页非offset LambdaQueryWrapperDish wrapper new LambdaQueryWrapper(); wrapper.like(name ! null, Dish::getName, name) .eq(Dish::getStatus, 1) // 只查启用状态 .orderByDesc(Dish::getUpdateTime); dishService.page(dishPage, wrapper); return R.success(dishPage); }提示MyBatis-Plus的Page构造函数new Page(1,10)表示“取第1页每页10条”底层SQL为LIMIT 0,10MySQL或OFFSET 0 ROWS FETCH NEXT 10 ROWS ONLYOracle无需手动计算offset。3.2 多条件筛选的LambdaQueryWrapper构建技巧订单模块需支持按订单号、支付方式、结账时间范围三重筛选若用传统WHERE拼接易出SQL注入而LambdaQueryWrapper可安全链式构建// OrderController.java GetMapping(/list) public RPageOrder list( RequestParam(defaultValue 1) Integer page, RequestParam(defaultValue 10) Integer pageSize, RequestParam(required false) String number, RequestParam(required false) Integer payMethod, RequestParam(required false) String beginTime, RequestParam(required false) String endTime) { PageOrder orderPage new Page(page, pageSize); LambdaQueryWrapperOrder wrapper new LambdaQueryWrapper(); // 订单号模糊查询LIKE wrapper.like(number ! null !number.trim().isEmpty(), Order::getNumber, number); // 支付方式精确匹配 wrapper.eq(payMethod ! null, Order::getPayMethod, payMethod); // 时间范围查询between if (beginTime ! null endTime ! null !beginTime.trim().isEmpty() !endTime.trim().isEmpty()) { wrapper.between(Order::getCheckoutTime, beginTime, endTime); } orderService.page(orderPage, wrapper); return R.success(orderPage); }此处wrapper.like(...)和wrapper.between(...)的条件判断包裹在! null内确保未传参时不生效避免WHERE checkout_time BETWEEN NULL AND NULL的语法错误。3.3 文件上传Vue SpringBoot图片存储路径与Nginx代理配置菜品/套餐图片上传采用el-upload组件后端用MultipartFile接收但需注意两点SpringBoot文件大小限制在application.yml中必须配置spring: servlet: context-path: /sky http: multipart: max-file-size: 10MB max-request-size: 10MBNginx代理图片访问路径若图片存于服务器/opt/sky/images/需在Nginx中添加location代理location /common/ { alias /opt/sky/images/; }前端图片URL即为http://your-domain.com/common/xxx.jpg而非http://your-domain.com/sky/common/xxx.jpg因context-path已设为/skyNginx需剥离前缀。上传Controller代码// CommonController.java PostMapping(/upload) public RString upload(MultipartFile file) { if (file.isEmpty()) { return R.error(文件不能为空); } // 生成唯一文件名时间戳UUID String originalFilename file.getOriginalFilename(); String extension originalFilename.substring(originalFilename.lastIndexOf(.)); String fileName LocalDateTime.now().format(DateTimeFormatter.ofPattern(yyyyMMddHHmmss)) UUID.randomUUID().toString() extension; // 保存到服务器指定目录 String uploadDir /opt/sky/images/; try { File destFile new File(uploadDir fileName); if (!destFile.getParentFile().exists()) { destFile.getParentFile().mkdirs(); } file.transferTo(destFile); return R.success(/common/ fileName); // 返回可访问URL } catch (IOException e) { log.error(文件上传失败, e); return R.error(文件上传失败); } }4. 数据库设计陷阱逻辑删除字段与外键约束的取舍实践4.1 逻辑删除字段is_deleted的全局配置与隐患规避系统所有实体表用户、菜品、分类、订单均含is_deleted字段TINYINT类型0未删除1已删除MyBatis-Plus通过TableLogic实现自动SQL过滤。但需在application.yml中全局配置mybatis-plus: global-config: db-config: logic-delete-field: is_deleted # 全局逻辑删除字段名 logic-delete-value: 1 # 已删除值 logic-not-delete-value: 0 # 未删除值此配置使userMapper.selectList(null)自动追加WHERE is_deleted 0但带来两个隐患4.1.1 关联查询时逻辑删除失效当查询订单详情OrderDetail时若OrderDetail表无is_deleted字段而关联的Dish表有MyBatis-Plus不会自动为Dish添加AND is_deleted 0。解决方案是在OrderDetailMapper.xml中手写SQL!-- OrderDetailMapper.xml -- select idlistWithDish resultTypecom.sky.vo.OrderDetailVO SELECT od.id, od.name, od.image, d.price as dishPrice, od.number, od.amount FROM order_detail od LEFT JOIN dish d ON od.dish_id d.id AND d.is_deleted 0 WHERE od.order_id #{orderId} /select4.1.2 物理删除操作需绕过逻辑删除管理员点击“彻底删除”时需执行物理删除而非逻辑删除。此时必须禁用全局逻辑删除// CategoryServiceImpl.java Transactional public void remove(Long id) { // 1. 先检查是否有子菜品外键约束 int dishCount dishMapper.countByCategoryId(id); if (dishCount 0) { throw new RuntimeException(该分类下存在菜品不能删除); } // 2. 物理删除临时禁用逻辑删除 LambdaQueryWrapperCategory wrapper new LambdaQueryWrapper(); wrapper.eq(Category::getId, id); // 使用BaseMapper自带的delete方法不走逻辑删除 categoryMapper.delete(wrapper); }4.2 外键约束弃用为什么用应用层校验替代数据库级FKMySQL表设计中dish表的category_id字段本应设为外键引用category表但系统实际未创建外键约束原因有三分布式扩展性未来可能将分类服务拆分为独立微服务数据库分库后外键失效批量操作性能导入1000条菜品时每条INSERT都触发外键校验耗时增加30%开发调试便利性测试环境常需清空dish表再导入测试数据若有外键需先删category顺序耦合。替代方案是应用层强校验// DishServiceImpl.java Override public void save(DishDto dishDto) { // 1. 校验分类是否存在应用层外键检查 Category category categoryMapper.selectById(dishDto.getCategoryId()); if (category null) { throw new RuntimeException(分类不存在 dishDto.getCategoryId()); } // 2. 保存菜品 dishMapper.insert(dishDto); }此方式将约束逻辑收口至Service层既保证数据一致性又保留架构演进空间。4.3 时间字段统一处理create_time/update_time的自动填充策略所有表含create_time创建时间、update_time更新时间字段避免手动new Date()出错。MyBatis-Plus通过TableField注解实现public class Dish implements Serializable { TableField(fill FieldFill.INSERT) // 插入时填充 private LocalDateTime createTime; TableField(fill FieldFill.INSERT_UPDATE) // 插入和更新时填充 private LocalDateTime updateTime; TableField(fill FieldFill.INSERT) // 插入时填充 private Long createUser; TableField(fill FieldFill.UPDATE) // 更新时填充 private Long updateUser; }还需配置自动填充处理器Component public class MyMetaObjectHandler implements MetaObjectHandler { Override public void insertFill(MetaObject metaObject) { LocalDateTime now LocalDateTime.now(); this.strictInsertFill(metaObject, createTime, LocalDateTime.class, now); this.strictInsertFill(metaObject, updateTime, LocalDateTime.class, now); // 获取当前登录员工ID从ThreadLocal中取 Long currentId BaseContext.getCurrentId(); this.strictInsertFill(metaObject, createUser, Long.class, currentId); this.strictInsertFill(metaObject, updateUser, Long.class, currentId); } Override public void updateFill(MetaObject metaObject) { this.strictUpdateFill(metaObject, updateTime, LocalDateTime.class, LocalDateTime.now()); Long currentId BaseContext.getCurrentId(); this.strictUpdateFill(metaObject, updateUser, Long.class, currentId); } }注意BaseContext.getCurrentId()需在登录成功后存入ThreadLocal登出时清除否则多线程下ID错乱。5. 生产环境部署 checklistNginx反向代理、MySQL字符集、Redis连接池调优5.1 Nginx反向代理配置解决Vue路由history模式404Vue Router使用history模式时直接访问/dish/list会触发Nginx 404因无对应静态文件。需在Nginx中配置try_filesserver { listen 80; server_name your-domain.com; location / { root /opt/sky/frontend/dist; index index.html; try_files $uri $uri/ /index.html; # 关键所有路径回退到index.html } location /sky/ { proxy_pass http://127.0.0.1:8080/; # SpringBoot后端地址 proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } }5.2 MySQL字符集与排序规则避免中文乱码与模糊查询失效建库时必须指定utf8mb4字符集支持emoji且排序规则用utf8mb4_0900_as_cs大小写敏感CREATE DATABASE sky_take_out DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_as_cs;并在application.yml中配置JDBC URLspring: datasource: url: jdbc:mysql://localhost:3306/sky_take_out?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/ShanghaiallowPublicKeyRetrievaltrueuseSSLfalse若未设serverTimezoneAsia/ShanghaiSpringBoot启动时会报The server time zone value UTC is unrecognized。5.3 Redis连接池参数调优应对并发写入瓶颈默认Lettuce连接池参数过于保守高并发下易出现Cannot get Jedis connection。在application.yml中调整spring: redis: host: 127.0.0.1 port: 6379 lettuce: pool: max-active: 20 # 最大连接数 max-idle: 10 # 最大空闲连接 min-idle: 0 # 最小空闲连接 max-wait: 10000 # 获取连接最大等待时间ms实测表明当max-active设为20时100并发用户同时刷新菜品列表Redis响应延迟稳定在2ms内若保持默认8延迟飙升至200ms以上。5.4 SpringBoot Actuator健康检查快速定位服务不可用原因启用Actuator端点通过/actuator/health查看各组件状态management: endpoints: web: exposure: include: health,info,metrics,prometheus endpoint: health: show-details: always访问http://localhost:8080/actuator/health返回{ status: UP, components: { diskSpace: {status: UP}, ping: {status: UP}, redis: {status: UP}, // Redis连接正常 db: {status: UP} // MySQL连接正常 } }若redis状态为DOWN说明Redis服务未启动或密码错误若db为DOWN检查MySQL连接URL与账号权限。最后一步验证启动SpringBoot应用后直接访问http://localhost:8080/login.html前端静态资源需放在src/main/resources/static输入默认账号admin/123456即可进入员工管理后台——此时你已站在一个真实外卖系统的入口接下来要做的是把它变成你简历里那个“可演示、可讲解、可深挖”的毕设项目。本文还有配套的精品资源点击获取