
这阵子学校教务系统又到了选课高峰期学生群里一片哀嚎——要么网页打不开要么选到一半被挤掉线。我正好接了个英语学院的学生选课系统项目前端用的是 uniapp vue目标平台是微信小程序后端是 ssmSpring SpringMVC MyBatis那一套。整个项目从需求梳理、数据库设计、接口开发到小程序端联调、打包上架一路走下来踩了不少坑也沉淀了不少可复用的经验。这篇文章就围绕这个选课系统把我实际开发过程中的技术选型逻辑、核心业务实现、联调细节、发布后才发现的问题原原本本分享出来给正在做类似管理系统、课程类小程序的朋友一个参考。1. 选课系统这套技术栈为什么最终定下 uniapp vue ssm选课系统不是啥新东西市面上现成的也有但英语学院这个需求有点特殊要支持微信小程序端访问要跟学院自己的教务数据打通还要处理分级选课、先修课校验这些个性化规则。技术栈看起来就是常见的 uniapp vue ssm但每一项其实都有取舍逻辑。1.1 前端为什么押注 uniapp vue而不是纯原生小程序我最早纠结过一个问题直接用微信原生小程序写不就行了选课逻辑其实不复杂原生也能搞定。但后来想了想学院之后很可能要出 App 端或者 H5 端毕竟学生有的用安卓、有的用 iOS还有人习惯电脑上选课。如果用原生小程序后面每加一个端都要重写一遍维护成本翻倍。uniapp 在这里的价值就是一套代码多端编译。它基于 vue 语法我写的是 vue 的组件和生命周期最后通过 HBuilderX 编译成微信小程序代码之后要打包 App 也就动动配置的事。实际上后来学院确实加了需求要一个安卓安装包我直接用 uniapp 云打包出了 apk基本没改业务代码。这个优势在项目中期体现得特别明显。vue 本身也帮了大忙。选课页面里的课程列表、筛选条件、已选课程状态这些数据绑定和组件复用用 vue 写起来非常顺手。比如课程卡片的开关状态我用 v-model 绑定一个 selected 数组选课和退课的 UI 刷新完全是响应式的不需要手动操作 DOM。对于我一个人既要搞后端又要搞前端的情况vue 的开发效率意味着我能少加班。1.2 后端沿用 ssm是保守也是务实后端我选了 ssm没上 Spring Boot也没搞微服务。原因很现实这个项目的部署环境在学院自己的服务器上运维同学对 Spring SpringMVC MyBatis 这套老配置非常熟出了问题他们能直接上手而且选课系统的并发量虽然瞬时高但也就几千人同时点选ssm 加 MySQL 完全撑得住没必要引入更重的框架。Spring 管 Bean 和事务SpringMVC 管接口路由MyBatis 管 SQL。这套组合在 Java 后端领域太经典了网上资料多得是遇到问题搜一下就有答案。我的核心配置就这么几块Spring 配置文件管理数据源、事务管理器、Mapper 扫描SpringMVC 配置注解驱动和视图解析器MyBatis 的 XML 文件写所有 SQL 语句选课的 SQL 比较复杂要用到行级锁和条件更新MyBatis 的 XML 方式反而比 JPA 那种自动生成 SQL 的方式更可控我可以精确到每一条 update 语句的行为。1.3 技术选型对比表格为什么没走其他路线技术方案优点缺点我的选择理由微信原生小程序性能好、API 全不可跨端、开发慢后续要有 App 端放弃Vue Vant Web 网页开发快触达率低、无推送学院明确要小程序Spring Boot配置简化、社区热部署环境和运维不熟学院服务器环境老ssm 更稳ssm轻量、可控、资料多配置繁琐运维熟悉SQL 可控性强uniapp vue多端发布、vue 语法复杂原生功能需插件满足跨端需求和开发效率这套组合用下来整体开发周期大概六周左右其中小程序端占了差不多三周后端两周联调和修 bug 一周。如果当初用原生小程序我估计前端时间要翻倍。2. 后端 SSM 的选课业务核心从数据表设计到并发选课冲突处理选课系统表面上是增删改查但核心难点在数据表设计和并发冲突处理。学生选课和秒杀抢购的逻辑本质上是一回事课程容量有限多人同时抢不能超卖还不能选冲突。这一块我花了不少心思。2.1 学生、课程、选课记录三张核心表如何设计数据库我设计成四张核心表除了常规的用户表、课程表、选课记录表还加了一张选课批次表。批次表很关键因为学院每学期会有好几轮选课比如第一轮公选、第二轮补选、第三轮退补选每轮的起止时间和规则都不一样。学生表student主要字段就是学号、姓名、英语分级A/B/C、专业、年级。英语分级这个字段是整个系统的特色因为英语学院的选课要按学生的英语水平分班教学A 班和 C 班能选的课程范围不同这个校验必须写到后端。课程表course的关键字段有课程编号、名称、授课教师、上课时间星期几 第几节、周次、容量、已选人数、所属分级、先修课程编号。设计时我把上课时间存成类似1-34的格式表示周一第三、四节方便做时间冲突校验。选课记录表course_selection就是学生和课程的多对多关系表字段包括 id、学号、课程编号、选课时间、状态。状态字段用来记录正常选中、退课、还是被管理员手动调整。三张表之间用外键逻辑关联但数据库层面我没建物理外键而是靠应用层保证数据一致性。原因一是选课系统写入频率高外键校验消耗性能二是后续要分表的话物理外键是障碍。实际开发中所有关联查询用 JOIN 或者 MyBatis 的嵌套查询解决一点问题没有。2.2 选课接口如何避免超选事务与行级锁配合选课最大的坑就是超选。想象一下课程容量 50 人第 50 个人和第 51 个人同时点了选课如果代码逻辑是先查一下当前已选人数小于 50 就插入记录然后更新已选人数那在高并发下必然超卖。原因很简单两个请求同时读到已选人数是 49都认为能选结果就变 51 人了。解决这个问题我用了数据库行级锁方案。核心就一条 SQLUPDATE course SET selected_count selected_count 1 WHERE id #{courseId} AND selected_count capacity这条 SQL 的巧妙之处在于update 操作会锁住这行记录第二个事务必须等第一个事务提交后才能执行。而条件selected_count capacity保证了就算同时进来多个请求只有一个能更新成功。MyBatis 里我这样写update iddecreaseStock UPDATE course SET selected_count selected_count 1 WHERE id #{courseId} AND selected_count lt; capacity /update这个 update 返回的影响行数如果是 1说明扣减成功选课记录才插入如果返回 0说明课程已经满了直接提示学生课程容量不足。整个过程必须包在一个事务里Transactional public SelectionResult selectCourse(String studentId, Integer courseId) { // 1. 校验学生分级是否匹配课程要求 // 2. 校验先修课程是否已通过 // 3. 校验时间是否冲突 // 4. 课程容量扣减 // 5. 插入选课记录 }注意事务一定要加在业务方法上让容量扣减和选课记录插入保持原子性。如果扣减成功了但插入失败事务回滚容量也会恢复不会出现选课记录没有但人数少了的脏数据。2.3 时间冲突校验一种简单但可靠的做法时间冲突是选课系统另一个绕不开的问题。学生不能同时上两门课所以我要校验新选的课程时间是否和已选课程重叠。我的实现方式是在 Java 里做内存校验查出学生所有已选课程的时间段和新课程的时间段逐一比对。时间段用两个字段表示星期几weekday和节次范围startPeriod 到 endPeriod。我封装了一个方法private boolean isTimeConflict(ListCourse selectedCourses, Course newCourse) { for (Course c : selectedCourses) { if (c.getWeekday().equals(newCourse.getWeekday())) { if (c.getStartPeriod() newCourse.getEndPeriod() newCourse.getStartPeriod() c.getEndPeriod()) { return true; } } } return false; }这个判断条件c.start new.end new.start c.end是区间重叠判断的标准公式直观理解就是两个时间段只要存在交集就返回 true。比如已选课程是周一 3-4 节新课程是周一 4-5 节3 5 且 4 4重叠成立冲突。有人可能会问这个逻辑放数据库做不行吗能行比如可以查exists判断但那个 SQL 写起来比较绕而且在 Java 里做可以顺带把课程名、教师名等详细信息返回给前端展示冲突原因体验更好。选课并发量再大也是几千级别内存遍历几十条已选课程完全不是瓶颈。3. 小程序端从 0 到 1uniapp 构建流程与几个绕不开的页面问题前端这块我用 uniapp 从零搭起来过程中遇到了不少网上讨论度很高的典型问题比如加载页修改、顶部导航栏高度适配、软键盘遮挡输入框等我都一一踩过并解决了。3.1 HBuilderX 创建项目运行到微信开发者工具没反应怎么排查用 HBuilderX 创建 uniapp 项目很简单新建项目选默认模板就行框架选 vue2 或 vue3 根据自己熟悉程度来。我用的 vue2因为项目的其他同事更熟。项目结构大概是这样├── pages │ ├── index // 首页/课程列表 │ ├── detail // 课程详情 │ ├── mine // 我的课表 │ └── login // 登录页 ├── static ├── App.vue ├── main.js ├── manifest.json └── pages.json很多人第一次运行到微信开发者工具时会遇到没反应的问题——HBuilderX 里点了运行微信开发者工具却没自动打开。这个坑排查起来其实就几个点首先确认微信开发者工具是否开启了服务端口。在微信开发者工具里点设置 - 安全设置打开服务端口开关这个必须开启HBuilderX 才能通过命令行调用它。其次确认 manifest.json 里的微信小程序 AppID。如果没填用的测试号也要确保微信开发者工具里登录的是同一个账号。很多情况下没反应就是 AppID 和当前登录账号不匹配导致的。最后检查一下 HBuilderX 的运行配置看看是否选择了正确的运行平台。运行 - 运行到小程序模拟器 - 微信开发者工具这个路径必须选对选成运行到浏览器那自然没反应。3.2 修改刚进入的加载页面自定义启动图和加载过渡修改刚进入的加载页面这个问题在热词里出现了确实很常见。uniapp 编译成微信小程序后刚启动时会有一个原生启动图这张图来自微信小程序的配置不是 uniapp 能直接改的。很多新手以为改了 uniapp 的 pages.json 里的导航栏背景色就能换启动图其实不对。原生启动图的尺寸和路径在微信小程序里由app.json的window节点的backgroundColor和项目的启动图配置决定。不过 uniapp 提供了自己的一套方案在manifest.json的微信小程序配置里可以设置小程序的启动图但实际开发中更常见的做法是做一个自定义引导页。我的做法是增加一个 splash 页面作为小程序的首页停留 1.5 秒后跳转到真正的课程列表页。这样启动时的视觉效果是可控的可以在 splash 页放学院 logo、选课公告、系统提示等。跳转逻辑我写在 splash 页的onLoad里onLoad() { setTimeout(() { uni.reLaunch({ url: /pages/index/index }); }, 1500); }用reLaunch而不是navigateTo因为 splash 页是启动首页如果用户按返回键不应该又回到 splash 页。这个细节很重要不然会出现返回键陷入死循环的体验问题。3.3 微信小程序顶部导航栏高度自定义导航的适配方案选课页面的顶部导航栏我最终选择了自定义导航而非默认导航。原因是要在导航栏里放一个学期切换的下拉选项默认导航不支持在标题旁边放自定义组件。一旦自定义导航就要自己处理顶部状态栏高度。微信小程序的顶部由两部分组成状态栏显示时间、电量那个区域和导航栏。状态栏的高度在不同机型上不一样比如 iPhone X 系列和普通安卓机差别很大。我之前踩过这个坑页面内容直接顶到了状态栏下面特别难看。解决方案是获取系统信息动态计算高度const systemInfo uni.getSystemInfoSync(); this.statusBarHeight systemInfo.statusBarHeight; // 自定义导航栏高度 状态栏高度 44px44 是导航栏标准高度 this.navBarHeight systemInfo.statusBarHeight 44;拿到高度后用 padding-top 把页面内容顶下去。如果某个页面忘了做这个适配在全面屏手机上就会出现内容被刘海遮挡的问题。后来我把这个逻辑封装成了一个 mixin所有页面统一引用再也没出过问题。3.4 手机软键盘遮挡查询内容的处理选课系统里有一个非常高频的操作查询课程。学生在搜索框输入课程名或教师名时弹起的软键盘会挡住下面的搜索结果列表。这个问题热词里也专门提到了uniapp 微信小程序手机软键盘会遮挡住查询内容。我在模拟器上测试时根本发现不了这个问题因为模拟器没有手机软键盘。真机上一测试就露馅了输入完关键词键盘一弹刚显示的搜索结果被键盘挡住一半。uniapp 官方提供的adjust-position属性默认是 true理论上输入框会被键盘顶上去。但问题是搜索结果是绝对定位或者是 scroll-view 的时候这个属性不一定生效。我的解决思路是用 scroll-view 包裹结果列表并动态调整 scroll-view 的高度scroll-view :style{height: listHeight px} scroll-y !-- 课程列表 -- /scroll-view在输入框获得焦点时监听onKeyboardHeightChange获取键盘高度动态计算列表可视高度onFocus() { uni.onKeyboardHeightChange(res { this.keyboardHeight res.height; this.listHeight this.windowHeight - this.keyboardHeight - this.searchBarHeight; }); }这个方法实测很稳定在安卓和 iOS 上都能正确响应。需要注意的是onKeyboardHeightChange要在页面卸载时移除监听否则下次进入页面时可能会重复触发。4. 前后端联调实战登录态、请求封装和接口细节上的坑前端页面写完只是第一步真正的硬仗在联调阶段。小程序端和后端 ssm 交互涉及微信登录凭证、跨域问题、请求封装、异常提示等一堆细节这里面隐藏的坑特别多。4.1 微信登录与后端 ssm 的 session 对接选课系统需要识别学生身份我用的是微信授权登录。流程是标准的微信小程序登录前端调用uni.login获取 code然后把 code 传给后端后端拿着 code 加上小程序的 appid 和 secret请求微信接口换取 openid后端用 openid 查学生表就能确定是哪个学生。我的后端接口设计是POST /api/login 参数{ code: xxx } 返回{ token: uuid, studentInfo: {...} }后端拿到 code 后调用微信接口String url https://api.weixin.qq.com/sns/jscode2session?appid appid secret secret js_code code grant_typeauthorization_code; // 用 HttpClient 或 RestTemplate 发起请求解析 openid拿到 openid 后我先查 student 表如果不存在说明这个微信没有绑定学生账号返回错误提示请先联系管理员绑定学号。如果存在就生成一个 token 返回给前端。这个 token 我用 UUID 生成存在 Redis 里并设置两小时过期比存 session 更适合小程序的无状态请求特点。前端每次请求都在 header 里带 token后端用拦截器校验。我在 SpringMVC 配置里加了一个 HandlerInterceptor处理所有 /api/ 开头的请求token 无效直接返回 401前端收到 401 就跳转登录页。4.2 前端 uni.request 封装统一处理 token 和错误码小程序端不可能每个页面都写一遍uni.request太啰嗦了。我封装了一个request.js所有接口都走这个封装的函数。核心逻辑包括export function request(url, method GET, data {}) { return new Promise((resolve, reject) { uni.request({ url: BASE_URL url, method, data, header: { Content-Type: application/json, token: uni.getStorageSync(token) }, success: (res) { if (res.statusCode 401) { uni.removeStorageSync(token); uni.navigateTo({ url: /pages/login/login }); return; } if (res.data.code 200) { resolve(res.data.data); } else { uni.showToast({ title: res.data.msg, icon: none }); reject(res.data); } }, fail: (err) { uni.showToast({ title: 网络异常请稍后重试, icon: none }); reject(err); } }); }); }这里有个细节后端接口的返回格式我统一设计成{ code: 200, msg: success, data: {...} }这是一个规范问题前后端必须统一约定否则联调时各自为政效率极低。我在项目一开始就定义好了接口文档规范后面联调省了很多扯皮时间。4.3 选课结果的状态反馈不能只靠提示文字选课这个操作对学生来说是即时性的他们要立刻知道自己选没选上。我的选课按钮有三种状态可选蓝色、已选灰色、已满灰色禁用。这是前端通过课程对象里的selected字段和selected_count capacity条件判断的。但这里有个信息差前端展示的容量数据是进入页面时查的如果课程在页面停留期间被选满了前端并不知道所以真正选课那一刻还是要以后端返回的结果为准。这就要求选课按钮的交互必须有加载态。我点击选课按钮后会把这个课程的 id 存到一个正在提交的集合里按钮变成加载中状态防止用户反复点击。等接口返回成功或者失败再更新对应的课程状态。这个加载态非常关键没有它的话用户连续点击会导致后端收到多个选课请求虽然后端有锁不会超卖但用户体验会很差还会增加无谓的接口压力。4.4 运行到微信开发者工具上没反应的完整排查链路之前简单提了一下没反应的问题但我觉得有必要把完整排查链路写出来因为这个问题太常见了。我遇到的场景是这样的HBuilderX 点运行到微信开发者工具控制台提示运行成功但微信开发者工具一直没有打开小程序。我的排查路径是按顺序来的第一步看微信开发者工具的服务端口是否开启。打开微信开发者工具 - 设置 - 安全设置 - 服务端口确认是开。这一步能解决大约一半的问题。第二步检查 HBuilderX 的运行日志。HBuilderX 控制台会有详细日志如果显示Build failed或Cannot find module说明项目本身编译不过去 pages.json 或 main.js 找语法错误。第三步确认微信开发者工具的项目目录。HBuilderX 运行后会自动在项目unpackage/dist/dev/mp-weixin目录生成编译产物。我手动去微信开发者工具里点导入项目选择这个目录能直观看到编译产物有没有成功。如果目录为空说明编译根本没完成。第四步检查 manifest.json 的微信小程序配置项确认 AppID 是否填写正确。如果用的是测试号需要把 AppID 字段清空微信开发者工具里选测试号。这套排查流程走下来99% 的没反应问题都能解决。剩下的 1% 是缓存问题重启 HBuilderX 和微信开发者工具再试一次基本就好了。5. 打包上架与后续维护manifest 配置和那些发布后才发现的坑项目上线不是终点发布过程中还有一堆问题。uniapp 打包微信小程序、上架安卓应用市场、真机适配这些问题每一项都值得单独说说。5.1 uniapp 微信小程序上传、审核、发布全流程微信小程序的上传流程相对简单。HBuilderX 里发行 - 小程序-微信编译成功后会在unpackage/dist/build/mp-weixin生成代码。然后用微信开发者工具打开这个目录点右上角的上传按钮填写版本号和备注代码就上传到了微信公众平台的版本管理里。提交审核前有几个容易被忽略的点类目选择很重要。选课系统属于教育类目需要提供相关的资质证明比如办学许可证。如果资质不全审核会被驳回。虚拟支付问题。微信小程序不允许做虚拟商品支付选课系统如果涉及学费支付要特别小心我做的版本不涉及在线支付所以避开了这个雷。用户隐私保护指引。小程序如果调用了用户信息需要在后台填写隐私保护指引否则审核也会卡住。发布后还有一个维护问题微信小程序每一次代码更新都要走一遍上传 - 提交审核 - 审核通过 - 发布的流程周期最快也要半天。所以上线前一定要充分测试不要频繁发版。5.2 uniapp 打包安卓应用市场的 manifest 配置学院后来提出想要安卓安装包这就涉及 uniapp 打包 App 了。HBuilderX 的云打包很方便不用本地配 Android 环境但打包前 manifest.json 的配置很重要。在 manifest.json 的App 模块配置里我最关心的有三个点一是推送但学院不需要离线推送就没勾选二是地图选课系统不带地图功能也没勾选三是定位同样不需要。一定要按需勾选勾的多会增大安装包体积拖慢加载速度。打包时要填 Android 包名我写的com.college.course包名一旦确定后续不能随意修改否则会直接影响应用市场更新。云打包需要一个 DCloud 账号免费用户有打包次数限制学院项目我直接充了个会员图个省心。安卓上架应用市场又是另一套流程。华为、小米、OPPO、vivo 这些市场的审核标准不一样但基本都需要软著证书、隐私政策链接、应用截图等材料。软著我提前一个月申请了因为周期比较长如果临时申请会拖慢上架进度。我个人的建议是先上华为和 OPPO这两个市场的用户量最大而且审核相对快。5.3 发布后才发现的两个真机问题项目上线后陆陆续续收到一些反馈其中有两个问题特别有代表性。第一个是 iOS 端输入框被键盘顶上去。这个小程序在微信里跑本质上还是 H5 渲染iOS 的 Safari 内核有个特性输入框聚焦时如果页面有 fixed 定位元素整个页面会被顶上去。有些学生反映在搜索课程时输入关键词页面整体上移键盘收起后页面回不到原位。我最初用adjust-position没用后来查资料发现是 uniapp 的已知问题官方建议用page-meta组件的 属性来控制页面滚动。加上root-font-size配合adjust-positionfalse强制让页面不被顶起然后自己在输入框下方预留空间问题才得到解决。第二个是安卓端特定机型的样式兼容问题。有一个学生用某品牌折叠屏课程列表的课程卡片出现错位图片和文字叠在一起。调试后发现是折叠屏的屏幕宽度比较特殊而 uni-app 的 rpx 单位在不同宽度下的换算导致某个 flex 布局的宽度计算溢出。我的解决方式是在课程卡片外层加一个最小宽度限制再用flex-wrap: wrap保证子元素溢出时自动换行而不是把整个卡片撑破。这两个问题在模拟器和普通机型上完全复现不出来只有真机特定环境才触发这提醒我一件事小程序开发不能只依赖模拟器有条件一定要多借几台真机测试特别是 iOS 和安卓各来一台能在发布前拦截大量兼容性问题。6. 自定义分享和后续优化选课系统还能怎么延伸分享功能虽然不在最初的定制需求里但学生选课之后经常有互相问你选了啥课的需求我后来加了一个自定义分享功能。这个功能表面简单但有个值得注意的坑。6.1 onShareAppMessage 的自定义分享实现微信小程序的分享默认是分享当前页面标题和图片都是默认的很不美观。我实现的自定义分享是在课程详情页里让用户分享这门课给同学对方打开小程序后直接定位到课程详情。onShareAppMessage() { return { title: 我在选课系统中发现了${this.course.name}这门课快来看看, path: /pages/detail/detail?id${this.course.id}, imageUrl: this.course.cover }; }分享的path里带了课程 id对方点开小程序后会直接进入详情页并在onLoad里解析options.id加载课程信息。这个逻辑听起来简单但我踩过一个小坑分享出去的 path 必须是一个完整的小程序页面路径如果写成相对路径就会打不开。分享标题也是运营细节不要用选课系统这种干巴巴的标题带课程名的分享标题转化率高得多。我把分享图和课程封面绑定这样朋友圈里看到分享卡片时视觉上更有吸引力。6.2 后续优化方向生成式对话和课表提醒热词里提到了 uniapp 生成式对话我也研究了一下。英语学院其实有个潜在需求——学生选课前想咨询这门课适不适合自己。如果接入一个大模型对话能力学生可以自然语言提问我英语基础不太好适合选 C 班的口语课吗系统基于课程数据和大模型生成回答。这个功能技术上可行开发计划里已经列上了但需要注意小程序端的对话内容合规问题必须做敏感词过滤。另外一个是课表提醒。学生选了课后经常忘记上课时间我用小程序的订阅消息功能实现课前提醒。这里有个现实约束微信订阅消息是一次性的每次要用户授权而且授权次数有限。我的方案是在学生选课后弹窗引导授权订阅授权成功后在选课记录里存一个计划任务到上课前给用户推送提醒。这个属于体验优化项不影响核心功能等核心稳定后再加也不迟。六周多的开发时间我最大的体会是选课系统这种管理类项目技术难点不在功能多而在边界多。容量扣减要考虑并发时间冲突要考虑边界自定义导航要考虑机型适配就连一个启动页的跳转逻辑都要考虑返回键的行为。每解决一个问题都会沉淀出一条经验这些经验比代码本身更值钱。希望这篇文章能帮正在做 uniapp ssm 项目的人少走点弯路尤其是并发处理和真机适配那两块都是我在项目上线前后真金白银换来的教训。