微信小程序+SSM校园食堂预约就餐系统设计与实现要点解析
简介:这是一套面向校园食堂场景的微信预约就餐小程序源码包,基于Java与SSM(Spring、SpringMVC、MyBatis)框架开发,适合毕业设计选题、小程序开发学习者以及需要快速搭建预约系统的开发者参考。压缩包共860个文件,整体约28.65MB,包含java后端源码、vue前端页面、wxml/wxss小程序页面、json配置文件、sql数据库脚本以及png/svg等界面素材,可覆盖前端展示、后端交互、数据持久化与项目运行配置等完整链路;目录结构清晰,便于按模块阅读、调试与二次开发。功能上覆盖用户登录、菜品浏览、在线预约、订单管理等核心场景,前后端分层设计合理,能够帮助理解微信小程序与SSM框架的完整联调流程,也可作为校园食堂高峰期预约分流方案的落地参考。目前已有151人学习下载,源码标注可运行成功,既适合初学者从零梳理项目结构,也适合有基础者作为毕业设计或课设的基础模板,按需扩展座位选择、实时人数展示等功能。
1. 一个压缩包背后的两类技术:小程序前端与SSM后端
“weixin245食堂校园预约就餐小程序ssm.rar”这个文件名,懂行的人一眼就能看出它的构成:微信小程序做用户端,后端用的是SSM,也就是Spring + SpringMVC + MyBatis。很多课设、毕设和实训项目都用这套组合,原因不外乎三点:Java生态成熟、关系型数据库支撑能力强、网上模板多。而“食堂校园预约就餐”这个业务,核心诉求也非常清晰——饭点不用排队,后厨按预约量备餐,学生线上下单到点取餐。它不像商城那样堆功能,但登录、预约、库存、订单流转、并发控制这些点一个不缺,适合刚学完Java Web想完整做落地系统的开发者。如果你拿到了这样一个压缩包,先别急着解压跑起来,把它拆开看懂链路:前端怎么调后端,后端怎么写SQL,事务怎么控制,小程序合法域名怎么配,这些才是真正值钱的东西。
2. 从需求到数据表:先想清楚预约就餐要管哪些数据
2.1 功能边界:用户端小程序和后台管理端各管什么
如果只盯着“预约”两个字,很容易把系统做成一个简单的增删改查。真正用起来之后你会发现,用户看到的几个页面,只是整个系统的三分之一。用户端小程序需要做到:微信授权登录、查看食堂列表、按日期和时段看菜品与剩余名额、提交预约、查看我的预约、取消未取餐的预约。后台管理端则至少包括:食堂和档口维护、菜品与备餐份数维护、预约时段与名额上限设置、订单核销、按天统计预约量。
这里我建议把“时段”单独抽成一张表,而不是直接把时间写在订单表里。因为食堂是分波次开餐的,不同时段的可接待人数不一样,同一个菜品在不同时段的备餐份数也不一样。把“时段”做成数据表后,你就有了一个天然的名额池,预约下单时对这个名额池做扣减,统计报表也能直接按时段分组。菜品也不要直接挂在食堂下,最好挂到窗口或档口,这样调整一个窗口菜单时不会影响其他食堂的数据。
权限设计上不要只依赖前端隐藏按钮。后台管理端接口要有独立的登录校验,至少区分“普通管理员”和“超级管理员”。如果是课程设计,可以在用户表加一个role字段:0代表普通用户,1代表管理员,2代表超管;如果项目要真实落地,建议把管理员单独拆表,不要和微信用户混在一张表里,否则一个普通用户拿到role=1的权限就能绕过前端操作后台接口。
2.2 六张核心表:用户、食堂、菜品、时段、预约订单、订单明细
下面这套表结构是我做这类预约系统时最常用的一组,字段故意保持精简,方便你对照理解,生产环境下再补审计字段和扩展字段。
CREATE TABLE user ( id BIGINT AUTO_INCREMENT PRIMARY KEY, openid VARCHAR(64) NOT NULL UNIQUE COMMENT '微信用户唯一标识', nickname VARCHAR(64), phone VARCHAR(20), role TINYINT DEFAULT 0 COMMENT '0普通用户 1管理员', created_at DATETIME DEFAULT CURRENT_TIMESTAMP, KEY idx_openid (openid) ); CREATE TABLE canteen ( id BIGINT AUTO_INCREMENT PRIMARY KEY, name VARCHAR(64) NOT NULL, location VARCHAR(128), open_time VARCHAR(32) COMMENT '如 10:30-13:30', status TINYINT DEFAULT 1 COMMENT '1营业 0停用' ); CREATE TABLE dish ( id BIGINT AUTO_INCREMENT PRIMARY KEY, canteen_id BIGINT NOT NULL COMMENT '所属食堂', name VARCHAR(64) NOT NULL, price DECIMAL(6,2) NOT NULL, stock INT DEFAULT 0 COMMENT '今日剩余份数', total_stock INT DEFAULT 0 COMMENT '今日备餐份数', status TINYINT DEFAULT 1, KEY idx_canteen (canteen_id) ); CREATE TABLE time_slot ( id BIGINT AUTO_INCREMENT PRIMARY KEY, canteen_id BIGINT NOT NULL, slot_date DATE NOT NULL COMMENT '哪一天', start_time VARCHAR(5) NOT NULL COMMENT '10:30', end_time VARCHAR(5) NOT NULL, max_orders INT NOT NULL COMMENT '该时段最大预约单数', current_orders INT DEFAULT 0 COMMENT '已预约单数', status TINYINT DEFAULT 1 COMMENT '1可预约 0关闭', UNIQUE KEY uk_slot (canteen_id, slot_date, start_time) ); CREATE TABLE booking_order ( id BIGINT AUTO_INCREMENT PRIMARY KEY, order_no VARCHAR(32) NOT NULL UNIQUE COMMENT '取餐号', user_id BIGINT NOT NULL, slot_id BIGINT NOT NULL, dish_id BIGINT NOT NULL COMMENT '一个订单对应一个菜品,简化版', quantity INT DEFAULT 1, total_price DECIMAL(8,2), status TINYINT DEFAULT 0 COMMENT '0待取餐 1已完成 2已取消', create_time DATETIME DEFAULT CURRENT_TIMESTAMP, KEY idx_user (user_id), KEY idx_slot (slot_id), CONSTRAINT fk_order_user FOREIGN KEY (user_id) REFERENCES user(id), CONSTRAINT fk_order_slot FOREIGN KEY (slot_id) REFERENCES time_slot(id) ); CREATE TABLE booking_item ( id BIGINT AUTO_INCREMENT PRIMARY KEY, order_id BIGINT NOT NULL, dish_id BIGINT NOT NULL, quantity INT DEFAULT 1, price DECIMAL(6,2), CONSTRAINT fk_item_order FOREIGN KEY (order_id) REFERENCES booking_order(id) );建表时有几个细节值得注意。user表里openid单独索引,因为登录第一步就是按openid查用户;time_slot表用(canteen_id, slot_date, start_time)做联合唯一键,避免同一个食堂同一天同一时间出现两条配置。booking_order里的order_no用字符串,取餐号给窗口阿姨报数字比报自增id更自然,而自增id留给内部关联。如果订单在未来可能包含多个菜品,就一定要拆booking_item明细表,明细表里的price必须保存下单那一刻的快照,否则以后调整菜品价格,历史订单金额也会跟着变。
2.3 为什么订单要拆主表和明细表:一个预约可以包含多份菜
很多新手图简单,把多个菜品id拼成“1,2,3”存在一个字段里,或者直接在一个订单表里加多个dish字段。这种设计在展示当天确实方便,可一旦要统计“每个菜品卖了多少份”,就只能把字符串拆开再count,而且遇到修改价格的情况,历史订单全部对不上。
所以哪怕当前需求是一个时段只能订一个菜,我也建议保留订单和明细两张表。订单主表记录用户、时段、总金额、状态,明细表记录每个菜品当时的价格和数量。这样后面上线“一次预约多个菜品”“同一时段改菜”“加购”这些需求时,不用改动表结构,只加业务接口就行。订单总金额不要信前端传上来的数字,必须由后端根据明细表里的价格重新计算,这个原则从下单接口设计的时候就要定下来。
数据模型走到这里,预约系统最核心的并发问题已经浮出水面:time_slot.current_orders和dish.stock这两个计数器会被多个请求同时修改。如果下单逻辑是先select再update,两个请求同时读到余额充足,就会造成超卖。这个问题我先记在这里,第5章会专门展开说,因为它在课设里最容易做成“能演示,但扛不住真实饭点流量”。
3. 把SSM后端跑起来:从Controller到Mapper的核心接口实现
3.1 工程结构与Maven依赖:传统SSM的配置不能省
SSM项目用Maven管理依赖是主流方式。新建一个war包工程,源码目录大致是src/main/java放Controller、Service、Mapper接口,src/main/resources放Spring和MyBatis的XML配置,webapp下放web.xml和静态资源。下面是一份够用的pom依赖,注意版本之间的兼容性。
<dependencies> <dependency> <groupId>org.springframework</groupId> <artifactId>spring-context</artifactId> <version>5.3.29</version> </dependency> <dependency> <groupId>org.springframework</groupId> <artifactId>spring-webmvc</artifactId> <version>5.3.29</version> </dependency> <dependency> <groupId>org.springframework</groupId> <artifactId>spring-jdbc</artifactId> <version>5.3.29</version> </dependency> <dependency> <groupId>org.mybatis</groupId> <artifactId>mybatis</artifactId> <version>3.5.13</version> </dependency> <dependency> <groupId>org.mybatis</groupId> <artifactId>mybatis-spring</artifactId> <version>2.1.1</version> </dependency> <dependency> <groupId>com.alibaba</groupId> <artifactId>druid</artifactId> <version>1.2.20</version> </dependency> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <version>8.0.33</version> <scope>runtime</scope> </dependency> <dependency> <groupId>com.fasterxml.jackson.core</groupId> <artifactId>jackson-databind</artifactId> <version>2.15.2</version> </dependency> <dependency> <groupId>javax.servlet</groupId> <artifactId>javax.servlet-api</artifactId> <version>4.0.1</version> <scope>provided</scope> </dependency> </dependencies>Spring 5.3版本用的是javax.servlet,如果部署到Tomcat 10,就要换成jakarta.servlet,这个坑在本地和服务器环境不一致时会非常隐蔽。Druid是我做传统SSM用得最多的连接池,因为它自带一个监控页面,跑起来后能在浏览器里看当前连接数、活跃连接数、慢SQL,比后期靠猜省事得多。如果你不想引入额外监控,换成HikariCP也可以,但如果依赖已经按上面配好,就别混用。
Spring和MyBatis的XML配置有固定套路:spring-mvc.xml里开启注解驱动、配置Controller扫描包;spring-mybatis.xml里配置数据源、SqlSessionFactoryBean和MapperScannerConfigurer。最容易翻车的是两个扫描包重叠,导致Service层事务失效。我一般会这样分工:Controller扫描限定到controller包,Service扫描限定到service包,两个扫描器互不干涉,事务管理器单独配置到service包上。
3.2 微信登录:用code2session换openid,别把业务逻辑混进去
小程序端点击登录,本质是wx.login拿到一个临时code,后端再拿code去微信接口换openid和session_key。openid是你的小程序里对用户的唯一身份,session_key用于解密手机号等敏感信息,后端不要长时间保存它,用完即弃。
@RestController @RequestMapping("/api/user") public class UserController { @Autowired private UserService userService; @PostMapping("/login") public Result login(@RequestBody LoginRequest req) { // req.getCode() 来自小程序端 wx.login 的返回值 String url = "https://api.weixin.qq.com/sns/jscode2session?appid=" + appId + "&secret=" + appSecret + "&js_code=" + req.getCode() + "&grant_type=authorization_code"; String response = HttpClientUtils.get(url); JSONObject json = JSONObject.parseObject(response); String openid = json.getString("openid"); if (openid == null) { return Result.error("登录失败:" + json.getString("errmsg")); } User user = userService.findByOpenid(openid); if (user == null) { user = new User(); user.setOpenid(openid); user.setNickname("微信用户"); userService.register(user); } // 生成 token 返回给小程序端,后续请求头里携带 String token = UUID.randomUUID().toString().replace("-", ""); redis.set(token, user.getId().toString(), 7 * 24 * 60 * 60); return Result.ok().put("token", token).put("user", user); } }这段代码有几个细节值得注意。appId和appSecret不要硬编码在Java类里,至少放到properties配置文件中,Maven打包时用profile区分开发环境和生产环境。HttpClientUtils是工具类,底层可以用HttpURLConnection或者Apache HttpClient,如果你直接用我示例方法名,记得在pom里补上对应的依赖。token存Redis比存数据库更方便,因为它天然支持过期时间,也不需要额外写清理任务;如果你们项目没接Redis,也可以用数据库表存token,但记得加expire_time字段并定期清理。
code是一次性的,只能使用一次,前端如果既在App启动时调了wx.login,又在页面某个按钮上调了一次,第二次换openid一定会失败。设计登录接口时,建议只接收code这一个参数,不要接收用户昵称、头像等资料。昵称头像可以在拿到openid后以“完善资料”的方式另行提交,避免登录接口被乱传参数污染。
3.3 预约接口的核心逻辑:先查后插再加事务,防止超卖
预约下单是整套系统里最核心的一段代码。业务要求很简单:用户选一个时段、一个菜品,提交预约。但你要保证同一个时段不会约超,同一个菜品也不会超出备餐份数。最直接、可靠的办法是:先查询time_slot的当前预约数是否小于上限,查询dish的stock是否大于0,条件都满足时才插入订单,同时给两个计数器加一。这个流程必须放在同一个事务里,否则两个请求同时查到同样的剩余值,就会双双下单成功,最终超过限量。
看下面这个MyBatis注解版Mpper和Service组合,完整代码我做过精简,核心逻辑都有。
public interface TimeSlotMapper { @Select("SELECT current_orders, max_orders FROM time_slot WHERE id = #{slotId} FOR UPDATE") TimeSlot selectForUpdate(Long slotId); @Update("UPDATE time_slot SET current_orders = current_orders + 1 WHERE id = #{slotId}") int increaseCurrentOrders(Long slotId); } public interface DishMapper { @Select("SELECT stock FROM dish WHERE id = #{id} FOR UPDATE") Dish selectForUpdate(Long id); @Update("UPDATE dish SET stock = stock - #{count} WHERE id = #{id} AND stock >= #{count}") int decreaseStock(@Param("id") Long id, @Param("count") Integer count); } @Service public class BookingService { @Transactional(rollbackFor = Exception.class) public Long createOrder(Long userId, Long slotId, Long dishId, Integer quantity) { // 第一步:锁定时段行 TimeSlot slot = timeSlotMapper.selectForUpdate(slotId); if (slot == null || slot.getStatus() != 1 || slot.getCurrentOrders() >= slot.getMaxOrders()) { throw new BizException("该时段已约满"); } // 第二步:锁定菜品行 Dish dish = dishMapper.selectForUpdate(dishId); if (dish == null || dish.getStatus() != 1 || dish.getStock() < quantity) { throw new BizException("菜品余量不足"); } // 第三步:创建订单主表记录 String orderNo = "R" + System.currentTimeMillis() + randomStr(4); BookingOrder order = new BookingOrder(); order.setOrderNo(orderNo); order.setUserId(userId); order.setSlotId(slotId); order.setDishId(dishId); order.setQuantity(quantity); order.setTotalPrice(dish.getPrice().multiply(new BigDecimal(quantity))); order.setStatus(0); orderMapper.insert(order); // 第四步:更新两个计数器 timeSlotMapper.increaseCurrentOrders(slotId); dishMapper.decreaseStock(dishId, quantity); return order.getId(); } }这段代码有两个关键点。第一个是select ... FOR UPDATE,它会把time_slot和dish中的对应行锁住,其他事务必须等当前事务提交或回滚后才能继续读这一行。这在课设和中小流量下完全够用;如果预约活动集中到一秒几千单,行锁会让并发尖峰变排队,到那时可以考虑改成乐观锁:先不带FOR UPDATE查,然后执行UPDATE ... WHERE current_orders < max_orders,靠受影响行数判断是否成功。第二种方式没有锁等待,但会有部分请求直接失败,需要前端提示“当前排队人数过多”。
第二个是@Transactional(rollbackFor = Exception.class),这个参数千万不能省。Spring默认只对RuntimeException回滚,如果你的自定义异常BizException继承的是Exception,不加rollbackFor就完全不会回滚。结果就是订单插进去了但库存没减,或者库存减了但订单回滚了,出现脏数据。
接口入参方面,userId不要由前端传,而是根据token从Redis里查出来。这样既可以防止用户伪造别人的ID,也方便在SpringMVC拦截器里做统一登录校验。quantity要做上限控制,比如一个用户一个时段最多预约3份,防止有人一次性约掉整个窗口的大部分名额。
4. 小程序端开发与联调:把后端接口装进微信开发者工具
4.1 项目结构:app.js放全局域名,request封装带上token
小程序端第一件事不是写UI,而是把请求层封装好。微信小程序的wx.request默认不允许直接请求IP和http接口,但开发者工具里可以勾选“不校验合法域名、web-view(业务域名)、TLS版本以及HTTPS证书”,这样开发阶段就能直连本地后端的8080端口。
下面是我常用的request.js,放在utils目录下,所有页面调用这个封装。
const BASE_URL = 'http://localhost:8080/api'; function request(path, method = 'GET', data = {}) { const token = wx.getStorageSync('token'); return new Promise((resolve, reject) => { wx.request({ url: BASE_URL + path, method, data, header: { 'Content-Type': 'application/json', 'token': token || '' }, success(res) { // 后端统一返回 { code, msg, data },code=0 表示业务成功 if (res.data.code === 0) { resolve(res.data.data); } else { wx.showToast({ title: res.data.msg || '请求失败', icon: 'none' }); reject(res.data); } }, fail(err) { wx.showToast({ title: '网络异常', icon: 'none' }); reject(err); } }); }); } module.exports = { request, BASE_URL };封装的核心思路是统一在请求头里带token,避免每个页面手写一遍header。同时约定HTTP200只代表网络层收到响应,真正的业务是否成功看body里的code。这样后端返回“时段已约满”“token过期”这类业务错误时,前端只需要在统一层做拦截,不需要每个页面单独判断。token过期时,后端返回一个定义好的状态码,比如401或者10001,前端request.js可以统一跳转登录页,而不是让用户停在当前页面反复看到“系统错误”。
4.2 预约页面:从菜品列表到下单的交互流程
预约主流程一般是:首页展示食堂列表,点进某个食堂看到菜品和时段,选择一个时段,提交预约。这里直接影响体验的不是动画效果,而是剩余名额的实时性。预约系统里列表数据会随其他用户下单而变化,所以页面onShow里最好都拉一次最新数据,而不是只在onLoad里请求一次。
如果食堂菜品数量多,列表页要加分页加载。常见做法是利用页面的onReachBottom事件,触底时请求下一页,参数带上pageNum和pageSize,后端用LIMIT分页返回。这样首屏加载快,也不会一次拉回大量数据导致渲染卡顿。
下面是一个提交预约按钮的典型写法。
Page({ data: { slotId: null, dishId: null, quantity: 1, submitting: false }, onSubmit() { if (!this.data.slotId || !this.data.dishId) { wx.showToast({ title: '请选择时段和菜品', icon: 'none' }); return; } if (this.data.submitting) return; this.setData({ submitting: true }); request('/order/create', 'POST', { slotId: this.data.slotId, dishId: this.data.dishId, quantity: this.data.quantity }).then(() => { wx.showToast({ title: '预约成功', icon: 'success' }); // 下单成功后把当前页面标题改为“我的预约”,再跳转订单页 wx.setNavigationBarTitle({ title: '我的预约' }); wx.navigateTo({ url: '/pages/orders/orders' }); }).catch(() => { // 具体错误提示已经在 request 封装里统一处理 }).finally(() => { this.setData({ submitting: false }); }); } });submitting标志位是防重复点击的第一道防线。如果不在前端控制,用户在弱网环境下连点两次,前端会发出两个完全一样的请求,后端即便有事务也会生成两个订单,用户体验会非常奇怪。前端拦截一次,后端事务再兜底一次,订单重复问题就基本能被压住。
页面顶部导航栏的高度在小程序里不是固定值,不同机型不一样。如果预约页要做自定义导航栏,建议调用wx.getMenuButtonBoundingClientRect()拿到胶囊按钮的位置,再计算整个导航栏的内容布局,否则按钮容易和微信自带的胶囊重叠。这个细节在日常联调里不算硬需求,但真机一测就能发现问题。
4.3 开发者工具Network面板:回包不对先看状态码和请求体
前后端联调阶段,我最常用的工具不是外部抓包,而是微信开发者工具自带的Network面板。打开调试器,切到Network标签,能看到每个wx.request的请求URL、方法、Headers、Payload和Response。多数“后端明明写了接口,小程序就是调不通”的问题,用这个面板十秒就能定位。
比较常见的几种情况:请求显示404,先看路径里的/api是不是拼错;显示500,点开Response看异常堆栈,通常SQL字段和实体类没对应上;显示“域名不合法”,就是上面说的开发环境没勾“不校验合法域名”。还有一种更隐蔽的情况:点击按钮后请求根本没发出,打开Console发现是js运行时报错,比如token获取失败导致header里出现undefined。Network面板还能直接看到接口耗时,如果首页接口超过500ms,优先看SQL是不是没走索引。
真机调试时,Network面板的数据没有开发者工具里那么全,建议先在工具里调通,再转到真机复测。真机上遇到“加密算法不支持”之类的报错,多半是TLS版本过低,需要后端nginx把TLS配置升级到1.2以上。这些坑不是逻辑问题,但会让上线前一晚非常狼狈。
5. 避坑:预约系统最常见的5个坑,从登录态失效到并发超卖
这一章我集中写预约系统里踩过的坑。每一条都是真实遇到过的情况,按“现象、原因、解决”的顺序拆开讲,你可以直接对照自己的代码排查。
5.1 真机预览请求失败:域名校验和IP地址
现象:开发者工具里一切正常,一点“预览”用手机扫二维码打开,所有接口全部失败,报request:fail。
原因:开发者工具里勾了“不校验合法域名”,所以走通;真机上小程序默认禁止请求http和任意IP,只认微信后台配置过的合法域名。真机预览时手机访问不到你电脑上的localhost,就算你想用局域网IP,微信也会拦截。
解决:开发阶段真机调试时,可以在小程序开发者工具中打开“真机调试”功能,并勾选“不校验合法域名”,但要注意只有开发版才有这个选项。正式环境必须在微信公众平台的后台“开发设置-服务器域名”里配置request合法域名,域名必须是https,而且该域名必须已经完成备案。我自己的做法是给后端挂一个nginx转发,域名用https://api.你的域名.com,小程序里的BASE_URL根据编译环境切换。
需要特别提醒的是,如果你用的是云服务器,国内节点不带备案域名是无法被微信校验通过的。课程设计阶段用局域网IP顶一顶可以,真上线别硬来。这个坑不解决,小程序永远只能停留在开发者工具能跑的阶段。
5.2 @Transactional没生效:订单重复插入
现象:用并发工具模拟20个用户同时预约同一个时段,数据库里出现了超过max_orders的订单数量,有的时段明明总量是10,最后入库了13单。
原因:最常见的有三种。第一,Service类没有加@Service注解,Spring根本没有管理这个Bean。第二,方法被同类内部调用,比如在同一个类里this.createOrder()调用另一个带有@Transactional的方法,导致Spring AOP的拦截逻辑没有触发。第三,事务管理器没有在Spring配置里开启,或者@EnableTransactionManagement没有启用。
解决:把需要事务的逻辑拆到独立的Service类里,通过注入的另一个Bean去调用,不要用this内部调用。排查时先在方法第一行输出一条日志,或者在接口里故意抛出一个RuntimeException,如果数据库里的数据没有回滚,说明事务确实没生效。还有一个小细节:你的数据库表引擎必须是InnoDB,MyISAM不支持事务,这在老项目里偶尔会碰到。
5.3 openid每回都变:登录逻辑写错了
现象:用户第一次登录下单成功,退出后再登录,历史订单找不到了,user表里出现一堆昵称相同但openid不同的记录。
原因:前端没有用wx.login的code去后端换openid,而是把wx.getUserProfile返回的昵称头像直接传到后端,用昵称去注册用户;或者前端把code和openid搞混了,把微信返回的临时code当成openid存进数据库。
解决:登录接口严格遵守“前端传code,后端换openid”的流程。wx.login每次返回的code都不同,但同一个微信用户换来的openid是固定的。后端拿到code后调微信的jscode2session接口,拿到openid后先查数据库,没有则新建用户。注意测试场景下,开发者工具里的登录态和真机不完全一致,如果用户数据混乱,先清缓存再测试。
另外,不要在前端缓存openid。openid属于敏感信息,存到本地容易被调试器看到。token存本地就够了,后端拿到token后再去Redis查对应的userId。
5.4 日期显示差8小时:时区配置不一致
现象:小程序上显示的下单时间是12:30,后台数据库里存的却是04:30,所有时间都差了8个小时。
原因:MySQL连接参数里没有指定serverTimezone,Java后端和数据库连接时用了不同的时区。MySQL驱动8.0之后,默认时区是UTC,而我们的业务时间用的是北京时间东八区。
解决:在JDBC连接串上明确加上serverTimezone=Asia/Shanghai&useSSL=false,同时把MySQL的系统时区也设置为+08:00。字段类型我建议用DATETIME,不用TIMESTAMP。TIMESTAMP随时区自动转换,在多人协作项目里容易出幻觉;DATETIME按我们写入的值原样存储,对预约这种明确依赖“自然日”的业务更可控。改完时区后再测一次,如果还差8小时,检查是不是Java虚拟机默认时区被改过。
5.5 列表数据没刷新:缓存和加载顺序混乱
现象:用户取消一个预约后回到首页,首页显示的名额还是旧的;下单成功后跳到“我的订单”,页面显示“暂无订单”,过几秒刷新又出现了。
原因:小程序页面默认会在App启动时保留之前的页面栈,从订单页返回首页时,首页不会自动重新请求数据。第二个问题的原因是跳转订单页的瞬间后端事务可能还没完成提交,或者订单数据接口没有在页面首次加载时被调用。
解决:在首页的onShow生命周期里主动刷新数据,而不是只在onLoad里请求一次。下单成功跳转前,确保后端接口返回成功后再wx.navigateTo,不要用wx.navigateBack立刻跳转。如果希望体验更好,可以在下单接口成功后提前更新本地缓存数据,但要注意取消预约时把缓存同步清掉。所有涉及名额列表的页面,都建议用服务端数据为准,本地缓存只做临时展示。
6. 上线前的验证:并发、时间窗和运营数据
预约系统做完能演示,只是第一步。真正敢放在同学面前用,至少要验证三件事。
第一件是并发。第3章给的select for update方案在单机小并发下没有明显问题,但你需要实测。用JMeter起50个线程,每个线程都请求同一个时段的预约接口,跑完后看订单表里到底进了多少单,对比time_slot表里该时段的上限。如果发现超卖,优先查数据库隔离级别和事务是否真的生效。最让人头疼的不是超卖本身,而是偶发性的超卖——单线程跑一切正常,并发一大就出错,这种问题必须压一下才能暴露。
第二件是预约时间窗口。不能让学生凌晨三点约第二天的午饭,也不能允许已经开餐了还在约当前时段。常见做法是在time_slot表里增加一个终止预约时间字段,下单时比对当前时间和终止时间,超时直接返回“不再接受预约”。这个时间窗口建议做成配置,因为食堂会临时调整备餐节奏,代码写死等于给自己埋雷。
第三件是运营数据。预约系统最核心的价值就是让备餐有依据。上线一周后,你应该每天统计每个时段的预约量、每个菜品的预约量、取消比例。取消率高的时段要查原因,预约量低的菜品要换菜单。统计SQL不复杂,用GROUP BY slot_date, slot_id就能出来,但要注意统计口径:如果你的业务支持取消后回补食材,current_orders字段不能直接等于订单总数,需要在统计脚本里单独处理取消订单。
我做这类系统时吃过一个亏:功能全部联调通过,结果第一天开放预约数据库连接池被打满,几百个请求全部超时。后来把Druid最大连接数调大,同时打开慢SQL日志,才发现有一个列表接口没做分页,一次查了全表几万行。从那以后我养成一个习惯:每个接口上线前在Network面板截图看耗时,超过500ms的必须优化后再发版。希望这篇能帮你少走这些弯路,让预约系统真正经得住饭点那一波流量。希望帮到你。
本文还有配套的精品资源,点击获取