1. 项目概述与整体设计思路
1.1 这个项目到底做了什么
先说项目本身。高校实验室管理系统,本质上是把实验室的预约、设备借用、耗材领用、使用记录这些线下流程搬到线上。学生通过微信小程序查看空闲实验室、提交预约;教师或实验员在管理后台审核、管理实验室和设备信息;系统管理员负责用户和权限。三个角色,两个端,一个后端服务。
这个标题里最关键的两个词是“微信小程序”和“前后端分离”。小程序端面向学生和教师,管理端用 Vue 搭建,后端统一用 Spring Boot 提供 RESTful 接口。我在实际开发中把整个工程拆成了三个目录:backend(Spring Boot 服务端)、admin-vue(PC 管理端)、miniapp(微信小程序端)。源码、文档、调试说明都按这个结构整理,拿到手的同学不需要花时间去猜代码放在哪里。
这类项目在高校里是真有落地场景的。实验室排课、开放预约、设备外借,以前靠纸质登记本,管理员每天要手动核对时间冲突,学生也不知道哪个时段有空位,只能一趟趟跑实验室。把预约流程线上化之后,核心价值就两个:降低管理成本,提高实验室利用率。对做毕设或者学习 Spring Boot + Vue + 小程序这套技术栈的同学来说,它也是一个麻雀虽小但五脏俱全的完整案例——有用户体系、有权限控制、有核心业务逻辑、有前后台交互。
1.2 为什么选这套技术栈组合
Spring Boot 负责后端接口和业务逻辑,微信小程序负责学生端的预约操作,Vue 负责 PC 管理后台。这个选型不是我拍脑袋定的,而是这类管理系统最常见的组合,原因很实在:
第一,微信小程序对学生端的触达成本最低。高校场景里学生不可能为了预约一个实验室去安装一个 APP,小程序扫码即用,用完即走,完全符合这个使用场景。第二,管理端用 Vue + Element Plus 做后台界面效率非常高,表格、表单、弹窗这些后台管理的常见组件都是现成的。第三,Spring Boot 天然适合做这种中小型系统的后端,内置容器、自动配置、生态成熟,写 CRUD 接口速度极快。
前后端分离在这个项目里的意义,不只是技术选型的问题,而是本身就存在两类使用终端。小程序跑在微信环境里,管理端跑在 PC 浏览器里,两者交互逻辑完全不同,物理上就必须分成两套前端。后端只需要统一暴露 JSON 接口,谁调用都行。这也方便后续扩展——将来要加一个 H5 端或者教师端 APP,前端重写就行,后端接口完全可以复用。
我一般建议第一次做这种全栈项目的同学,先把后端接口设计好,拿 Postman 把每个接口调通,再去做前端页面。否则前后端同时写,出了问题很难定位是接口的问题还是页面的问题。
2. 后端核心模块与数据库设计细节
2.1 核心业务模块拆解
后端按业务域拆包,每一个模块对应一张主表。整个项目里我最看重的几个模块是这样划分的:
- 用户模块:学生、教师、管理员三种角色,JWT 登录鉴权,微信小程序通过
wx.login换取 openid 自动注册。 - 实验室模块:实验室的基础信息、可用时间段、容量、设备清单的关联维护。管理端支持对实验室信息做增删改查。
- 预约模块:学生提交预约申请(选择实验室、时间段、用途),教师或实验员在管理端审核。这是整个系统的核心业务。
- 设备模块:实验室内的设备清单,管理员维护设备状态(正常/维修/报废),预约实验室时可以顺带查看可用设备。
- 使用记录模块:预约通过后自动生成使用记录,实验完成后可补充实际使用情况。
划分的原则就一条:高内聚、低耦合。每个模块之间通过接口交互,不互相直接操作表。比如预约模块不直接改实验室表的字段,而是通过查询判断冲突,这样后续改需求的时候不会牵连一片。
2.2 数据库表结构设计的几个关键决策
这部分是整个项目里最容易返工的地方。我第一版设计的时候就踩过坑,把预约信息、实验室信息、设备信息全塞在几张宽表里,后面加字段改需求非常痛苦。第二版按主题拆分后,结构清晰了很多。拿核心的预约表做例子:
| 字段名 | 类型 | 说明 |
|---|---|---|
| id | bigint | 主键,自增 |
| user_id | bigint | 预约人 ID,关联用户表 |
| lab_id | bigint | 实验室 ID,关联实验室表 |
| reserve_date | date | 预约日期 |
| start_time | time | 开始时段 |
| end_time | time | 结束时段 |
| purpose | varchar | 预约用途 |
| status | tinyint | 0待审核 1已通过 2已拒绝 3已取消 4已完成 |
| create_time | datetime | 提交时间 |
设计这张表的时候,我特意把 reserve_date、start_time、end_time 拆成三个字段,而不是合并成一个 datetime。原因在于:实验室预约大都是按“某天的某段时间”来组织的,白天分上午下午,晚上分几个课时段,日期和时段是天然分开的筛选维度。如果合成一个字段,后面做“某一天的所有预约记录”和“某个时段是否被占用”的查询条件都会变得很别扭。
另外一个关键决策是预约状态用 tinyint 而不是 varchar 存中文。用数字存状态,配合后端枚举类做映射,好处有两个:一是数据库体积小、查询效率高,二是避免了中文在不同字符集下可能出现的匹配问题。前端拿到 0-4 的数字后自己去映射显示文案,也不会因为数据库里的中文改了导致全链路崩溃。
2.3 数据访问层的选择与配置
数据访问我用的是 MyBatis-Plus,而不是原生 MyBatis。原因很简单:这个项目大部分操作都是单表 CRUD,MyBatis-Plus 的BaseMapper直接内置了selectById、insert、updateById、selectPage这些常用方法,我连 SQL 都不用写。只有在预约冲突检测这种需要多表联合查询的场景,才手写一条 SQL。
<select id="selectConflictReservations" resultType="com.example.entity.Reservation"> SELECT * FROM reservation WHERE lab_id = #{labId} AND reserve_date = #{reserveDate} AND status IN (0, 1) AND start_time < #{endTime} AND end_time > #{startTime} </select>这段 SQL 是预约模块的核心,看懂了它就理解了这个系统的防冲突逻辑。两个预约产生冲突的条件是:同一个实验室、同一天、时间段有交叉、且状态都是有效的(待审核或已通过)。时间段交叉的判断用的是“开始时间小于别人的结束时间,且结束时间大于别人的开始时间”,这个条件覆盖了包含、相交、首尾相接所有情况。我在初版写的时候只看了一眼两边完全相等的情况,结果一个 10:00-12:00 和一个 11:00-13:00 的预约同时在系统里通过了审核,后来测试才发现这个问题。
字段名里start_time和end_time在 MySQL 里不是保留字,可以直接用。但如果是desc、order这类词就得加反引号。这个项目里我给所有表字段都用了_分隔的命名风格(比如create_time、lab_id),因为 MyBatis-Plus 默认开启了驼峰命名映射,Java 里的createTime字段能自动对应数据库的create_time列,省掉了大量@TableField注解。
3. Vue 管理端的实现与前后端联调
3.1 Vue 开发环境搭建的版本坑
管理端我选的是 Vue 3 + Vite + Element Plus。这套组合在 2024 年已经是绝对主流了,但很多同学在环境配置阶段就被卡住。最常见的原因是两个:Node 版本太老,或者 npm 镜像源太慢。
Vite 5 要求 Node 版本 18 以上,如果你机器上还是 Node 14/16,启动就直接报错。我建议直接用 nvm 管理 Node 版本,指定安装 Node 18 LTS 即可。另一个是 npm 安装依赖,国内用户建议先设置镜像源,否则npm install卡半小时都装不完:
npm config set registry https://registry.npmmirror.com装好依赖后,npm run dev启动开发服务器。Vite 默认跑在 5173 端口,后端的 Spring Boot 跑在 8080 端口,这就涉及前后端联调最核心的问题——跨域。
3.2 跨域问题的两种解法
开发环境最简单的方案是用 Vite 的代理功能,在vite.config.js里配置:
export default defineConfig({ server: { port: 5173, proxy: { '/api': { target: 'http://localhost:8080', changeOrigin: true } } } })这样前端代码里请求/api/user/login,Vite 会自动转发到http://localhost:8080/api/user/login,浏览器的同源策略就不会拦截了。
后端也要做一层 CORS 兜底,因为生产环境前端是部署在 Nginx 上的,Nginx 直接转发/api开头的请求,但如果有人直接访问后端地址,跨域问题就会暴露出来。在 Spring Boot 里加一个配置类:
@Configuration public class CorsConfig implements WebMvcConfigurer { @Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping("/**") .allowedOriginPatterns("*") .allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS") .allowedHeaders("*") .allowCredentials(true) .maxAge(3600); } }这里踩过一个坑:allowCredentials(true)的时候,allowedOrigins不能写*,必须用allowedOriginPatterns,否则前端带 Cookie 或者 Token 的请求会被浏览器拦截。初次排查的时候我以为是跨域配置没生效,折腾了半天才发现是这个细节。
3.3 管理端的页面结构与权限控制
管理端页面按角色和功能划分,登录后根据用户角色动态渲染菜单。管理员能看到全部菜单,实验员只能看到实验室管理、预约审核和使用记录这几个模块。
路由权限控制的思路,是在路由对象里给每个路由配置一个meta.roles字段,登录后从后端获取当前用户的角色,通过 Vue Router 的beforeEach全局守卫做判断:
router.beforeEach((to, from, next) => { const token = localStorage.getItem('token') if (!token && to.path !== '/login') { next('/login') } else if (token && to.path === '/login') { next('/') } else { next() } })这里的逻辑比较基础,但演示了前端路由守卫配合 JWT 做页面级权限控制的基本套路。真正实用的角色权限控制一定会同时在后端接口层面再校验一遍:前端隐藏菜单只是用户体验,后端校验才是真正的安全边界。
管理端的核心页面就是预约审核页。这个页面的数据量大、交互复杂,我用的是 Element Plus 的el-table+el-pagination做分页列表,每条记录后面根据状态展示不同的操作按钮:待审核状态的显示“通过”和“拒绝”,已通过的显示“完成”。这里又涉及一个细节——按钮要根据状态动态显示,我把状态判断逻辑抽成了计算属性,而不是在每个按钮上写一堆v-if,维护起来清爽很多。
4. 微信小程序端的关键实现细节
4.1 登录与手机号获取的现状
小程序端第一个绕不开的模块就是登录。2023 年之后,微信官方调整了手机号快速验证组件的规则,原来那个通过wx.login拿到 code 再调getPhoneNumber获取手机号的旧方案已经废弃了。现在比较稳妥的做法是:基础登录用wx.login换取 openid,在服务端校验通过后颁发 JWT;如果需要手机号,就引导用户点击页面上的<button open-type="getPhoneNumber">,通过bindgetphonenumber事件拿到动态令牌,再传给后端解密获取真实手机号。
我在这个项目里简化了一步:登录时只拿 openid 作为用户唯一标识,手机号作为可选项在个人资料页补全。原因是预约审核本身是校内流程,学生对身份的识别主要靠学号,手机号只是联系用的辅助信息,没必要在登录环节强制要求。
服务端对应的接口接收code之后,调用微信的jscode2session接口:
public String code2Session(String code) { String url = String.format( "https://api.weixin.qq.com/sns/jscode2session?appid=%s&secret=%s&js_code=%s&grant_type=authorization_code", appId, appSecret, code); RestTemplate restTemplate = new RestTemplate(); String response = restTemplate.getForObject(url, String.class); return response; }这里要注意一个细节:appSecret绝对不能放在小程序前端代码里,只能放在后端。有同学把 secret 写在小程序里直接请求微信接口,等于把口令交给所有人,这个协议层面就是违规的。
4.2 顶部导航栏高度适配的通用解法
小程序的自定义导航栏在适配不同机型时经常出问题。标题里特别提到“顶部导航栏高度”,说明这是很多初学者的痛点。不同机型的状态栏高度不一样:iPhone X 系列有刘海,状态栏高度大概 44px,普通 Android 机大约是 24px。如果你做自定义导航栏,硬编码一个高度就会出现顶部遮挡或者空白过多的问题。
我常用的方案是在app.js的onLaunch里计算导航栏高度,存到全局变量,页面里动态绑定:
const systemInfo = wx.getWindowInfo() const menuButton = wx.getMenuButtonBoundingClientRect() const navBarHeight = (menuButton.top - systemInfo.statusBarHeight) * 2 + menuButton.height this.globalData.navBarHeight = navBarHeight this.globalData.statusBarHeight = systemInfo.statusBarHeightgetMenuButtonBoundingClientRect返回的是右上角胶囊按钮的位置信息。用“胶囊顶部到状态栏底部的距离 × 2 + 胶囊按钮高度”计算出来的导航栏高度,能保证不同机型上胶囊按钮都垂直居中在自定义导航栏里,这是目前适配性最好的方案。页面端就是一个 slot 布局,自定义导航栏的样式保持和系统导航一致——左侧返回箭头、中间标题文字,上下间距通过 padding 撑开。
4.3 预约流程的交互设计
小程序端预约流程我设计成四个步骤,每一步都尽量减少用户操作:
- 首页展示本周可预约的实验室列表,卡片上显示实验室名、位置、可容纳人数。
- 点击“预约”进入详情页,选择日期和时段。时段的选项由后端根据已通过审核的预约动态计算,已经被占用的时段直接置灰不可选。
- 填写用途(如课程实验、科研项目),提交后弹出确认框,展示预约的完整信息。
- 提交成功跳转到“我的预约”列表,可以查看当前所有预约的审核状态。
这个流程里最值得展开的是第二步。时段选择组件的数据来源不是前端写死的,而是后端实时计算返回的。后端在预约接口里先把实验室开放的所有基础时段返回给前端(如 08:00-10:00、10:00-12:00、14:00-16:00、16:00-18:00、19:00-21:00),然后查询该实验室当天的有效预约,把冲突的时段标记为不可选。这样用户在界面上看到的永远是真实可预约的时段,不会出现提交之后后端告诉你说“该时段已被预约”的尴尬情况。
提交预约和审核通过的接口都有重复提交的并发问题。我用了两个层面的保护:一是数据库层的唯一索引(lab_id, reserve_date, start_time, end_time),二是 MySQL 的select ... for update配合事务。在实际压测里,这两个措施基本把并发冲突挡掉了。后面在问题排查部分再细说。
5. 项目部署与上线调试全流程
5.1 三端部署的正确姿势
项目开发完成后,部署到服务器上要分三步走。顺序不能乱:
后端:mvn clean package打成 jar 包,扔到服务器上执行nohup java -jar lab-system.jar --spring.profiles.active=prod &。生产环境的数据库连接、微信小程序 appid、secret 这些配置写在application-prod.yml里,不写进代码仓库,这是底线要求。
管理端:npm run build构建出 dist 静态文件,放到 Nginx 的html/admin目录下,配置一个 server 块。Vue 项目部署最大的坑是路由的 history 模式——如果前端用了createWebHistory(),刷新页面就会出现 404。解决办法是在 Nginx 配置里加一个 try_files 回退:
location /admin/ { alias /usr/share/nginx/html/admin/; try_files $uri $uri/ /admin/index.html; }小程序端:微信开发者工具里点击“上传”,填写版本号和备注,然后在微信公众平台提交审核。这里有一个硬性要求:小程序请求的域名必须是 HTTPS 且已备案、已配置在后台的合法域名里。开发阶段可以在开发者工具里勾选“不校验合法域名”,但上线前必须关掉这个选项,否则真机预览直接失败。
5.2 接口联调与文档管理
接口文档我用的 Knife4j(Swagger 的增强版),集成到 Spring Boot 里非常方便,引入依赖后加一个配置类就能用。访问/doc.html就能看到所有接口的调试页面,支持在线传参测试。开发过程中,我要求自己每写完一个接口就同步写好注解,不要等全部写完了再补,不然容易漏。
小程序端请求封装不要直接拿wx.request硬写,一定要封装一层:
const request = (url, method = 'GET', data = {}) => { return new Promise((resolve, reject) => { wx.request({ url: `${baseUrl}${url}`, method, data, header: { 'Authorization': `Bearer ${wx.getStorageSync('token')}` }, success: (res) => { if (res.data.code === 200) { resolve(res.data.data) } else if (res.data.code === 401) { wx.navigateTo({ url: '/pages/login/login' }) } else { reject(res.data) } }, fail: reject }) }) }统一封装有几个实际好处:所有请求自动带 token、统一处理错误码、401 统一跳转登录页。我自己在开发中经常改后端接口的响应结构,如果每个页面的请求都是裸写的,改动量会非常大;有了这层封装,改一个地方就全局生效了。
5.3 真机调试的几个经验
小程序和 Vue 管理端有个本质区别:小程序跑在微信里,调试不能靠浏览器开发者工具,必须频繁用真机预览和扫码测试。
真机调试最常遇到的怪问题就是“请求失败”或者“网络异常”。排查思路按照下面的顺序来:
- 确认手机和开发电脑连接的是同一个局域网,本地开发时
baseUrl要写成电脑的局域网 IP(比如http://192.168.1.101:8080),不能写localhost。 - 确认微信开发者工具里“不校验合法域名”开关已打开,真机调试时这个功能需要在预览二维码弹窗里选择“真机调试 2.0”模式,基本都能解决。
- 确认后端接口的主机地址是能被手机访问的。很多同学困在“电脑上浏览器访问接口正常,手机小程序访问失败”,基本都是因为后端启动时绑定的地址是
127.0.0.1,改成0.0.0.0才能被局域网内其他设备访问。
我还遇到过一个小程序特有的问题:Android 手机上wx.request走 HTTP 明文会被拦截。微信官方要求线上环境必须 HTTPS,但在调试阶段,Android 机型的默认策略可能直接拒绝明文流量。处理方式是在微信公众平台的小程序开发设置里,临时把对应域名加到“开发调试白名单”,或者干脆用真机调试的 proxy 模式。
6. 常见问题与排查技巧实录
6.1 Spring Boot 版本相关的坑
这个话题反复被提到,“springboot版本太高”确实是这个项目最高频的坑。我用 Spring Boot 3.2.1 做后端,要求 JDK 17 以上。很多同学的电脑上默认装的还是 JDK 8,启动就报UnsupportedClassVersionError,或者干脆Exception in thread "main" java.lang.RuntimeException。
解决方案有两个。一个是你愿意升级环境,用 JDK 17 跑 Spring Boot 3.x,这是长期正解。另一个是坚持用 JDK 8,那就把 Spring Boot 版本降到 2.7.18,这个版本是 2.x 的最后一个版本,稳定性和文档都齐全。
另外特别注意,Spring Boot 2.7.x 和 3.x 的springdoc配置类写法有差异。如果你用 3.x,javax.servlet相关的依赖要全部换成jakarta.servlet,手写拦截器时import javax.servlet.*会编译不通过。我第一次升级到 3.x 时被这个问题卡了一整晚,后来用全局搜索把javax全部替换成jakarta才解决。
还需要提醒的是,不要在 pom.xml 里加一堆用不上的依赖。有同学参考别人的项目把自己需要的依赖全加进来,比如引入了 ActiveMQ、Flink 这种和实验室管理毫无关系的组件,导致启动变慢而且报一堆无意义的错误。这是很多“无效依赖导致排查困难”案例的根源。
6.2 小程序端异常问题的速查表
| 问题现象 | 排查思路 | 解决方案 |
|---|---|---|
| 小程序请求后端接口超时 | 检查后端地址是否用了 localhost | 改为局域网 IP,确认手机与电脑同网段 |
| 导航栏高度在不同机型上不一致 | 硬编码了高度值 | 用getMenuButtonBoundingClientRect动态计算 |
| 提交预约提示“请先登录” | token 过期或未正确传递 | 检查请求封装是否带了 Authorization 头 |
| 真机预览无法访问接口 | 后端绑定了 127.0.0.1 | 后端启动时绑定0.0.0.0 |
| 手机号获取组件报错 | 使用了旧的getPhoneNumber接口 | 改用手机号快速验证组件 |
| 头像上传失败 | 开发环境证书不合法 | 开发阶段用本地临时工具,生产环境用 HTTPS |
这里面最值得展开的是预约冲突导致的并发问题。两个学生同时提交同一实验室同一时段的预约,后端如果只是先查询再判断再插入,就会存在并发竞态。我在上面提过用数据库乐观锁处理,具体实现是给预约表加一个version字段,更新时UPDATE reservation SET status = 1 WHERE id = ? AND version = 1。如果更新影响行数为 0,说明数据已经被别的操作改了,直接提示用户失败。
对于初学者来说,我建议最简单的做法:先在数据库层面建一个针对(lab_id, reserve_date, start_time, end_time)的唯一索引,在业务代码里把这个竞争化为数据库的唯一约束异常,代码短且可靠。
6.3 前后端联调的其他踩坑经验
最后说几个很碎但很容易反复踩的细节。
后端返回的时间字段格式。Spring Boot 默认返回的时间格式是带毫秒的时间戳,比如2024-06-01T09:30:00.000+00:00,小程序的new Date()处理这种字符串偶尔会出现格式兼容问题。我给后端加了统一的 Jackson 配置,格式化日期为yyyy-MM-dd HH:mm:ss。一个小配置,能少掉一堆前端的日期解析异常。
前端请求参数和后端 Java Bean 的字段映射。前端习惯用驼峰命名(如userName),后端如果没用驼峰映射,数据库字段user_name就接收不到。确认 MyBatis-Plus 的map-underscore-to-camel-case配置已开启,否则数据对不上排查起来会想骂人。
接口返回的数据结构一定要前后端约定好。我项目里统一用的返回类是Result<T>,包含 code、message、data 三个字段。code 为 200 代表成功,401 未登录,500 服务器异常。前端请求封装层就是按这个结构解析的。如果某个接口返回结构不一致,页面拿到数据就undefined,但这种报错往往没有任何报错信息,排查全靠打断点。
调试阶段多打日志,别只靠 Postman 看响应。我习惯在后端每一个 controller 方法接收请求时打一行log.info("接收到预约请求: {}", reservation),在业务逻辑关键节点再打几行状态日志。生产环境出了问题,先翻日志定位是在哪一层挂掉的,效率比瞎猜高得多。
7. 源码交付与后续扩展方向
7.1 我习惯的源码交付结构
项目交付时,三个工程的目录划分我习惯这样组织:
lab-system/ ├── backend/ # Spring Boot 后端 │ ├── src/main/java │ ├── src/main/resources │ └── pom.xml ├── admin-vue/ # Vue 管理端 │ ├── src/ │ ├── package.json │ └── vite.config.js ├── miniapp/ # 微信小程序端 │ ├── pages/ │ ├── app.js │ └── app.json ├── sql/ │ └── lab_system.sql # 建库建表脚本 └── README.md # 部署说明文档README 文档我坚持要写清楚三件事:环境要求(JDK 版本、Node 版本)、启动步骤(先后端还是先前端)、默认账号密码。很多同学的毕设源码拿给别人跑不起来,90% 是缺了环境要求说明。另外 SQL 脚本要留着,不要只导出一个.sql文件藏在本机,交付时直接把初始化数据也带上,省得别人自己乱造数据。
7.2 系统后续还能扩展什么
这个项目做完之后,我根据自己的使用体会梳理了几个值得扩展的方向。
第一个是消息通知。目前预约审核的结果只能在管理端看到,学生打开小程序才能查看到。如果接入订阅消息或者企业微信机器人,预约通过后自动推送到学生微信,闭环体验会完整很多。
第二个是排课功能。高校实验室通常有固定的实验课程安排,目前的预约系统只处理学生自主预约。排课表和预约表如果合并成一张日程表,统一管理,能避免学生和老师各约各的,撞在一起。
第三个是数据统计分析。后端已经积累了预约记录、使用记录、实验室利用率这些原始数据,做一个 Dashboard 展示各实验室的使用率排行、各院系的预约占比、高峰时段的分布,对管理者是很有价值的辅助决策信息。管理端里我目前只做了一个最基础的统计页,按实验室和月份查使用次数,后续可以扩展成真正具备分析能力的数据看板。
项目做到这个程度,框架的搭建已经完成了。麻雀虽小,五脏俱全,从用户注册登录、角色权限、核心业务审批流,到前后端部署联调,全部跑通。剩下的事情,就是在真实使用中不断发现需求、补齐细节的过程。