ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Java高校校园点餐系统实战:Spring Boot+MyBatis从架构到避坑

Java高校校园点餐系统实战:Spring Boot+MyBatis从架构到避坑 简介这份资源是面向高校计算机相关专业学生与Java初学者的一套校园点餐系统完整项目可作为课程设计、毕业设计或Java Web练手参考。系统以Java为开发技术采用B/S结构与MySQL数据库围绕管理员、用户、食堂三类角色展开涵盖个人中心、用户管理、食堂菜单管理、菜系分类管理、消息留言与留言板管理、订单管理、我的收藏、购物车及前台首页等功能模块基本覆盖了点餐业务从浏览、下单到后台维护的完整流程。压缩包为zip格式整体约55.69MB文件类型明细上游暂未提供但可预期包含源码、数据库脚本与项目说明等常见内容。目前已有246人学习下载适合希望理解Java动态页面设计与后台数据库交互、需要一套可运行参考方案来梳理需求分析与系统设计思路的读者。1. 从食堂窗口到宿舍楼一套 Java 高校校园点餐系统到底在解决什么中午十二点下课铃一响三万人同时涌向两个食堂窗口前排队的队伍能拐三个弯——这是几乎所有高校都逃不掉的场景。Java 高校校园点餐系统要干的事很具体把人到窗口才能点变成人在教室就能下单让后厨按订单节奏出餐让学生到店即取或由配送员送到宿舍楼下。它不是一个通用外卖平台用户群体封闭、配送半径通常不超过两公里、营业时间跟着课表走这些约束决定了它的技术选型和商业外卖系统完全不同。这套系统适合三类人动手一是计算机专业做课程设计或毕业设计的学生需要一套能跑通、能讲清楚、能应付答辩的完整工程二是高校信息化部门的开发者想给本校食堂做一套轻量级订餐工具三是想练手 Spring Boot 全栈的 Java 初学者拿一个业务闭环完整的项目把 CRUD、事务、缓存、定时任务串一遍。下面按先想清楚架构、再动手写核心模块、最后处理那些只有真跑起来才会暴露的坑这条线展开中间会给可直接抄的代码和参数配置。2. 技术选型与库表设计为什么这套系统不该照搬外卖平台架构2.1 技术栈怎么定Spring Boot MyBatis 的最小可用组合高校点餐系统的并发特征很特殊峰值集中在四个时间点早中晚三餐加夜宵其余时间几乎没流量。这意味着不需要微服务、不需要分库分表一套单体 Spring Boot 应用加一个 MySQL 实例足够撑住。我一般会这样定技术栈层次选型理由后端框架Spring Boot 2.7.x生态成熟starter 依赖开箱即用答辩时评委也认持久层MyBatis-Plus单表 CRUD 不用写 XML复杂查询再手写 SQL比纯 JPA 可控数据库MySQL 8.0窗口函数、CTE 都能用JSON 字段存菜品规格方便缓存Redis存购物车、菜品库存、验证码减轻数据库压力前端Vue 3 Element Plus学生端和管理端共用组件库开发快定时任务Spring Task订单超时取消、每日营业统计轻量够用有人会问为什么不直接上 Spring Cloud。血泪经验是课程设计或小规模部署场景下微服务带来的运维复杂度远超收益一个 Nacos 注册中心挂掉就能让你排查一整天。单体应用拆好包结构controller / service / mapper / entity / dto后期真要拆也不难。依赖引入用 Maven核心几个 starter 如下!-- pom.xml 关键依赖 -- dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdcom.baomidou/groupId artifactIdmybatis-plus-boot-starter/artifactId version3.5.3.1/version /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-data-redis/artifactId /dependency dependency groupIdmysql/groupId artifactIdmysql-connector-java/artifactId version8.0.33/version /dependency逻辑说明spring-boot-starter-web提供内嵌 Tomcat 和 MVC 能力MyBatis-Plus 的 starter 会自动装配 SqlSessionFactory省去手写配置类Redis starter 默认用 Lettuce 连接池。参数上注意 MyBatis-Plus 版本要和 Spring Boot 版本匹配2.7.x 配 3.5.x 是稳的组合版本错配会出现NoSuchMethodError这类启动失败。2.2 库表设计六张核心表撑起整个业务闭环高校点餐的数据模型比外卖简单但有几个字段必须提前想清楚否则后期改表很痛苦。核心表如下-- 用户表学生和商家共用用 role 区分 CREATE TABLE user ( id BIGINT PRIMARY KEY AUTO_INCREMENT, open_id VARCHAR(64) DEFAULT NULL COMMENT 微信openId用于免登录, student_no VARCHAR(20) DEFAULT NULL COMMENT 学号校内身份标识, nickname VARCHAR(32) NOT NULL, phone VARCHAR(11) NOT NULL, role TINYINT NOT NULL DEFAULT 0 COMMENT 0学生 1商家 2配送员 3管理员, dorm_building VARCHAR(32) DEFAULT NULL COMMENT 宿舍楼栋用于配送分组, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_student_no (student_no) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 菜品表库存和上下架状态是关键 CREATE TABLE dish ( id BIGINT PRIMARY KEY AUTO_INCREMENT, shop_id BIGINT NOT NULL, name VARCHAR(64) NOT NULL, price DECIMAL(10,2) NOT NULL, stock INT NOT NULL DEFAULT 0 COMMENT 每日库存凌晨重置, status TINYINT NOT NULL DEFAULT 1 COMMENT 0下架 1上架, category VARCHAR(32) DEFAULT NULL, image_url VARCHAR(255) DEFAULT NULL ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 订单表状态机是核心 CREATE TABLE orders ( id BIGINT PRIMARY KEY AUTO_INCREMENT, order_no VARCHAR(32) NOT NULL COMMENT 业务订单号, user_id BIGINT NOT NULL, shop_id BIGINT NOT NULL, total_amount DECIMAL(10,2) NOT NULL, status TINYINT NOT NULL DEFAULT 0 COMMENT 0待支付 1已支付 2制作中 3待取餐 4已完成 5已取消, pickup_code VARCHAR(6) DEFAULT NULL COMMENT 取餐码, remark VARCHAR(255) DEFAULT NULL, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, pay_time DATETIME DEFAULT NULL, UNIQUE KEY uk_order_no (order_no), KEY idx_user_status (user_id, status) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 订单明细表 CREATE TABLE order_item ( id BIGINT PRIMARY KEY AUTO_INCREMENT, order_id BIGINT NOT NULL, dish_id BIGINT NOT NULL, dish_name VARCHAR(64) NOT NULL COMMENT 冗余存名称防止菜品改名, price DECIMAL(10,2) NOT NULL COMMENT 下单时价格快照, quantity INT NOT NULL ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 购物车表 CREATE TABLE cart ( id BIGINT PRIMARY KEY AUTO_INCREMENT, user_id BIGINT NOT NULL, dish_id BIGINT NOT NULL, quantity INT NOT NULL DEFAULT 1, UNIQUE KEY uk_user_dish (user_id, dish_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 配送表 CREATE TABLE delivery ( id BIGINT PRIMARY KEY AUTO_INCREMENT, order_id BIGINT NOT NULL, rider_id BIGINT DEFAULT NULL, dorm_building VARCHAR(32) NOT NULL, status TINYINT NOT NULL DEFAULT 0 COMMENT 0待接单 1配送中 2已送达, UNIQUE KEY uk_order (order_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;设计要点说明order_item里冗余存dish_name和price是刻意为之菜品改名或调价后历史订单不能跟着变这是订单系统的铁律。orders表的status字段用 TINYINT 而不是字符串查询效率高且状态流转清晰。idx_user_status联合索引服务于查我的待支付订单这类高频查询。cart表用uk_user_dish唯一键保证同一用户同一菜品只有一条记录加购时用INSERT ... ON DUPLICATE KEY UPDATE处理。注意stock字段的扣减必须用UPDATE dish SET stock stock - #{qty} WHERE id #{id} AND stock #{qty}这种带条件的原子更新不能先查再改否则并发下必然超卖。3. 下单主链路实现从加购到支付成功的完整代码3.1 购物车与库存预占Redis 和 MySQL 怎么分工购物车数据读写频繁但允许丢失放 Redis 最合适库存是钱相关必须落 MySQL。我一般这样分工加购时先写 Redis Hashkey 为cart:{userId}field 为 dishIdvalue 为数量下单时再把 Redis 购物车转成订单同时扣 MySQL 库存。Service public class CartService { Autowired private StringRedisTemplate redisTemplate; private static final String CART_KEY cart:%d; // 加购Redis Hash 自增 public void addToCart(Long userId, Long dishId, int quantity) { String key String.format(CART_KEY, userId); redisTemplate.opsForHash().increment(key, dishId.toString(), quantity); // 设置过期时间避免僵尸购物车占内存 redisTemplate.expire(key, 7, TimeUnit.DAYS); } // 获取购物车全部条目 public MapObject, Object getCart(Long userId) { return redisTemplate.opsForHash().entries(String.format(CART_KEY, userId)); } }逻辑说明opsForHash().increment是原子操作多个请求同时加购同一菜品不会丢数量。expire设 7 天学生放假回来购物车清空是合理的。参数上CART_KEY用%d占位符拼 userId避免 key 冲突。这里没做数量上限校验实际要在加购前查菜品是否上架、库存是否充足否则用户能加购一个已下架的菜。3.2 创建订单事务、订单号生成与库存扣减下单是整个系统最容易翻车的地方必须在一个事务里完成校验库存、扣库存、写订单、写明细、清购物车。任何一步失败都要回滚。Service public class OrderService { Autowired private DishMapper dishMapper; Autowired private OrderMapper orderMapper; Autowired private OrderItemMapper orderItemMapper; Autowired private StringRedisTemplate redisTemplate; Transactional(rollbackFor Exception.class) public String createOrder(Long userId, Long shopId, ListOrderItemDTO items) { // 1. 生成订单号时间戳 用户ID后四位 随机数 String orderNo System.currentTimeMillis() String.format(%04d, userId % 10000) ThreadLocalRandom.current().nextInt(1000, 9999); BigDecimal total BigDecimal.ZERO; // 2. 逐个扣库存扣失败直接抛异常回滚 for (OrderItemDTO item : items) { int affected dishMapper.deductStock(item.getDishId(), item.getQuantity()); if (affected 0) { throw new BizException(菜品[ item.getDishName() ]库存不足); } total total.add(item.getPrice().multiply(BigDecimal.valueOf(item.getQuantity()))); } // 3. 写订单主表 Orders order new Orders(); order.setOrderNo(orderNo); order.setUserId(userId); order.setShopId(shopId); order.setTotalAmount(total); order.setStatus(0); // 待支付 orderMapper.insert(order); // 4. 写订单明细 for (OrderItemDTO item : items) { OrderItem oi new OrderItem(); oi.setOrderId(order.getId()); oi.setDishId(item.getDishId()); oi.setDishName(item.getDishName()); oi.setPrice(item.getPrice()); oi.setQuantity(item.getQuantity()); orderItemMapper.insert(oi); } // 5. 清空 Redis 购物车 redisTemplate.delete(cart: userId); return orderNo; } }对应的 Mapper 扣库存 SQLupdate iddeductStock UPDATE dish SET stock stock - #{quantity} WHERE id #{dishId} AND stock #{quantity} AND status 1 /update逻辑说明deductStock的WHERE stock #{quantity}是防超卖的关键数据库行锁保证并发下只有一个请求能扣成功。Transactional(rollbackFor Exception.class)必须显式指定rollbackFor否则默认只回滚RuntimeException受检异常不会触发回滚这是新手最常踩的坑。订单号用时间戳加随机数单机场景够用如果多实例部署要换成雪花算法或 Redis 自增否则可能撞号。参数上ThreadLocalRandom比Random在高并发下性能更好避免多线程竞争同一个种子。total用BigDecimal而不是double金额计算绝不能用浮点数。3.3 支付回调与订单状态机支付成功后第三方会回调你的接口这里要做两件事验签和幂等。幂等靠订单状态判断——已经是已支付的订单再次回调直接返回成功不重复处理。PostMapping(/pay/callback) public String payCallback(RequestBody PayCallbackDTO dto) { // 1. 验签伪代码按实际支付渠道实现 if (!payChannel.verifySign(dto)) { return FAIL; } // 2. 查订单 Orders order orderMapper.selectByOrderNo(dto.getOrderNo()); if (order null) return FAIL; // 3. 幂等已支付直接返回 if (order.getStatus() 1) return SUCCESS; // 4. 更新状态用乐观锁防止并发 int affected orderMapper.updateStatus(order.getId(), 0, 1); if (affected 0) return SUCCESS; // 被其他线程处理了 // 5. 生成取餐码通知商家 String pickupCode String.format(%04d, ThreadLocalRandom.current().nextInt(1, 9999)); orderMapper.updatePickupCode(order.getId(), pickupCode); return SUCCESS; }状态更新的 SQL 用条件更新实现乐观锁update idupdateStatus UPDATE orders SET status #{newStatus}, pay_time NOW() WHERE id #{id} AND status #{oldStatus} /update逻辑说明WHERE status #{oldStatus}保证只有状态没变时才更新返回影响行数为 0 说明已被处理直接返回成功即可。取餐码用四位随机数同一时间段内可能重复实际要加唯一约束或按商家维度生成。回调接口必须返回支付渠道约定的格式这里是 SUCCESS否则渠道会不断重试。4. 避坑与排查那些只有真跑起来才会暴露的问题4.1 库存扣成负数并发下的超卖现象压测时发现某个菜品库存显示 -3明明下单前查过还有货。原因用了先 SELECT 查库存再 UPDATE 扣减的写法两个请求同时查到库存为 1都判断充足然后都执行扣减。解决改成UPDATE dish SET stock stock - #{qty} WHERE id #{id} AND stock #{qty}靠数据库行锁和 WHERE 条件保证原子性判断影响行数是否为 0 来决定是否抛异常。4.2 订单重复创建用户手抖点了两次提交现象同一个用户同一批菜品生成了两笔订单。原因前端按钮没做防抖或者网络慢用户重复点击。解决前端提交后立即禁用按钮后端在创建订单前用 Redis 做分布式锁key 为order:lock:{userId}SETNX加过期时间拿到锁才处理处理完释放。更彻底的做法是给订单表加用户时间窗口的唯一约束但实现复杂一般用锁就够了。4.3 定时任务把营业中的订单误取消现象用户刚下单还没支付几分钟后被系统自动取消了但用户其实正在付款。原因超时取消任务的判断条件只看了create_time没排除正在支付中的订单。解决取消条件加上status 0仅待支付并且给用户支付留足时间一般设 15 分钟。任务执行频率别太高每分钟扫一次即可扫描时用LIMIT分批避免一次性锁太多行。Scheduled(cron 0 * * * * ?) public void cancelTimeoutOrders() { // 只取消 15 分钟前创建且仍待支付的订单 ListOrders list orderMapper.selectTimeoutOrders( LocalDateTime.now().minusMinutes(15), 0, 100); for (Orders o : list) { orderMapper.updateStatus(o.getId(), 0, 5); // 0待支付 - 5已取消 // 回滚库存 orderItemMapper.selectByOrderId(o.getId()) .forEach(item - dishMapper.restoreStock(item.getDishId(), item.getQuantity())); } }4.4 Redis 和数据库库存不一致现象Redis 里显示有库存下单却提示库存不足。原因Redis 库存是缓存扣减后没同步或者同步失败。解决库存以 MySQL 为准Redis 只做展示层的缓存且设置较短的过期时间比如 30 秒下单时直接查 MySQL。不要试图用 Redis 做库存扣减的唯一依据除非你有一套可靠的消息队列保证最终一致那对高校项目来说过度设计了。4.5 中文乱码从数据库到前端全链路现象菜品名红烧肉在数据库里正常接口返回变成???。原因数据库连接 URL 没指定字符集或者表字符集不是 utf8mb4。解决JDBC URL 加useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai建表时统一用CHARSETutf8mb4Spring Boot 配置里加spring.http.encoding.charsetUTF-8。三个地方都对了才不会乱码。5. 让系统扛住饭点洪峰几个能立刻用上的调优技巧饭点十分钟内的请求量可能占全天的 60%这套系统能不能用就看这十分钟稳不稳。第一个技巧是接口限流用 Redis 做计数器对下单接口按用户维度限流比如每个用户 10 秒内最多提交 3 次public boolean tryAcquire(Long userId) { String key limit:order: userId; Long count redisTemplate.opsForValue().increment(key); if (count 1) { redisTemplate.expire(key, 10, TimeUnit.SECONDS); } return count 3; }increment返回 1 说明是第一次此时设置过期时间后续请求只自增不重设保证窗口滑动正确。这个逻辑简单但有效能挡住脚本刷单和用户误操作。第二个技巧是热点菜品缓存。销量前十的菜品信息名称、价格、图片放 Rediskey 为dish:hot:{shopId}过期时间 5 分钟。查询时先查缓存未命中再查库并回填。注意库存字段不要缓存或者缓存了也要在扣减后主动删除否则用户看到有货下单却失败体验极差。第三个技巧是订单查询走覆盖索引。用户查我的订单是最频繁的操作orders表的idx_user_status联合索引要保证查询能覆盖user_id和status避免回表。如果列表还要显示菜品名考虑在订单主表冗余一个dish_summary字段如红烧肉等3件省去关联查询。最后一个习惯上线前一定用 JMeter 压一遍下单接口。我一般设 200 并发跑 1 分钟观察三个指标——库存有没有扣成负数、订单号有没有重复、响应时间 P99 是否超过 2 秒。这三个指标过了饭点基本就稳了。压测时记得把日志级别调到 WARN否则光打日志就能把磁盘写满。这套系统我前后改过三版第一版没做库存原子扣减上线第一天就超卖了四十多份后厨做不出来只能挨个打电话道歉。从那以后我养成了一个习惯凡是涉及钱和库存的写操作先问自己一句——并发下会不会出错。希望这些经验能帮你少走点弯路把系统稳稳当当地跑起来。本文还有配套的精品资源点击获取
返回列表