教室预约与调课申请系统实战:Python Flask + uni-app 开发复盘 📅 发布时间:2026/9/15 9:13:20 👁 浏览次数: 做了几个校园小程序之后我发现“教室预约”和“调课申请”这两个模块单拆开看都不复杂但合到一起做成一套系统时真正考验人的其实是流程设计、数据约束和跨端适配。这个项目用 Python 写后端接口、用 uni-app 套壳微信小程序和 App 端前后大概花了三周时间才把预约、审批、调课、通知这条链路彻底跑顺。今天这篇就把我踩过的坑、调过的接口、修过的渲染问题一起复盘一遍给准备做校园业务系统的同学一个参考。1. 项目场景拆解你以为的简单预约其实是完整闭环1.1 教室预约的真实痛点不是“一个表单”而是一套资源调度规则教学楼管理员最怕的不是有人来借教室而是借出去的教室没用上、没借的教室空在那里到了考试周和活动季又出现两拨人同时看中同一间教室的火拼现场。传统的登记表一页页翻Excel 排期改来改去根本跟不上学校里的临时教室需求。所以这个系统里的教室预约本质上是解决“资源—时间—权限”的三元匹配哪间教室空闲、哪个时间段空闲、谁有资格预约。学生要预约一间多媒体教室做班会教师要为公开课申请带录播功能的阶梯教室这两类预约虽然入口一样但审批层级和优先级完全不同。我最初的设计只有一个预约表单后来被实际业务逼着加了“用途类型”和“优先级”两个字段才让审批规则有地方落地。1.2 调课申请为什么必须和预约做成一个系统调课申请如果单独做一个模块业务上是割裂的。比如老师周四下午在 A 教室有两节课因为外出开会想调到周五上午这时候系统要做的事情不是简单记录一条调课申请而是要同时处理两件事释放原教室的占用时间、请求目标教室的新时间段。这里最自然的做法就是把调课申请建立在预约数据之上。当老师发起调课时系统先读取他名下的原课程占用的教室时间段然后在同一个事务里生成一条“释放原教室 申请新教室”的复合操作。如果把预约和调课做成两张互不相干的表前端切换页面倒是爽了后端保证数据一致性的时候就会想撞墙。我做的时候直接把调课申请设计成“绑定了原预约记录”的一条新申请记录审批通过后原预约自动取消目标时间段写入新的预约记录这一整条链路才真正闭环。1.3 技术选型思路Python 后端 uni-app 小程序为什么这么搭后端选 Python 是权衡过的。当时团队里有同学熟悉 Flask也有同学熟 Django最终我定了 Flask SQLAlchemy。原因是这个项目的核心逻辑集中在预约冲突检测和审批状态流转路由和模型层不需要 Django 那么重的全家桶Flask 的蓝图结构配合装饰器做权限校验代码量更少出了问题也更好定位。前端选 uni-app 就比较现实了学校这边要求必须有微信小程序但同时也提了一句“以后可能要出 App”uni-app 一套代码同时编译到小程序和 App 端能省掉后面重复开发的成本。项目里几乎所有页面都是常规表单和列表uni-app 生态里的组件库已经完全够用不需要原生开发。唯一费劲的地方是 uni-app 在 iOS 上渲染某些内置组件时会有奇怪的兼容性问题后文我会单独展开讲一个最典型的坑。2. 后端设计与核心逻辑实现2.1 数据库表结构设计五个核心表撑起整个权限与流程整套系统的核心数据模型我拆成了五张表用户表、教室表、预约记录表、调课申请表、附件表。用户表除了常规的 openid、姓名、学号工号之外一定要留一个“角色”字段学生、教师、教务管理员这三类角色的权限边界完全不同。我当时用了一个简单的整型字段加枚举映射学生是 1教师是 2管理员是 3后续权限装饰器直接比较数值大小就行简单粗暴但在这种量级的系统里足够用。教室表要特别注意扩展性。除了教室名称、教学楼、楼层、容量这几个基础字段我加了“教室属性”这个 JSON 字段用来存多媒体、录播、智慧黑板这类设备信息。因为预约界面需要支持按设备属性筛选教室如果把这些属性建成单独的关联表反而会让查询变得啰嗦一个 JSON 字段配合代码里的过滤逻辑前端展示和后端筛选都省事。预约记录表是整个系统的重中之重核心字段我用 SQLAlchemy 写出来大概是这样class Reservation(db.Model): __tablename__ reservations id db.Column(db.Integer, primary_keyTrue) room_id db.Column(db.Integer, db.ForeignKey(rooms.id), nullableFalse) user_id db.Column(db.Integer, db.ForeignKey(users.id), nullableFalse) reserved_date db.Column(db.Date, nullableFalse) start_time db.Column(db.Time, nullableFalse) end_time db.Column(db.Time, nullableFalse) purpose db.Column(db.String(200), nullableFalse) status db.Column(db.String(20), defaultpending, indexTrue) priority db.Column(db.Integer, default0) create_time db.Column(db.DateTime, defaultdatetime.utcnow) approve_time db.Column(db.DateTime) approver_id db.Column(db.Integer, db.ForeignKey(users.id))这里有个细节容易被忽略状态字段一定要加索引。因为所有的列表查询、冲突检测、首页教室状态矩阵都要用状态字段做条件过滤不加索引的话数据量上去之后接口响应会肉眼可见地变慢。我们系统跑了一周之后预约记录到了两千多条没索引用 EXPLAIN 一看就是全表扫描后来补上索引才恢复流畅。调课申请表的设计我后面会单独讲它绕不开的状态流转比预约更复杂因为一次调课要动两个教室的时间片。附件表则是通用的业务类型字段区分是“预约证明材料”还是“调课申请附件”文件路径里我存的是相对路径配合 wx.env.user_data_path 这类前端本地目录使用避免把服务端磁盘路径暴露给客户端。2.2 冲突检测算法关键一段代码别把待审批和已批准分开处理教室预约最怕的就是冲突而冲突的根源在于“两个申请的时间段重叠”。检测两个时间段是否重叠逻辑并不复杂只要新的时间端 start 小于已有记录的 end并且新的 end 大于已有记录的 start就判定为重叠。写成查询条件就是overlap Reservation.query.filter( Reservation.room_id room_id, Reservation.reserved_date target_date, Reservation.start_time new_end_time, Reservation.end_time new_start_time, Reservation.status.in_([pending, approved]) ).first()我特意把 pending 状态也放进了冲突检测范围。这一点很多第一次做这类系统的同学容易漏掉如果只检查已批准的预约那两个人同时提交同一个教室的申请两笔都进入待审批状态管理员无论先通过哪一笔另一笔都变成了事实上的冲突。最合理的思路是“先到先得”只要有一个未结束的申请占据了这个时间段后提交的申请就应该被系统直接拦截或者至少给出明确的冲突提示。不过这里也有一个反过来的业务考量有的学校希望允许“冲突申请”存在然后由管理员在审批时人工仲裁。我实现的时候做成了一个可配置项系统默认开启强冲突拦截但管理员可以在后台关掉关掉之后预约照样能提交成功只是界面上会出现冲突警示标识。这种小功能虽然是边际需求但在真实使用中特别拉住用户好评因为教务老师的真实工作习惯就是灵活处理。2.3 审批状态机一张状态流转表省去一半流程代码预约和调课如果不用状态机代码里会到处散落 if else后期维护非常痛苦。我把所有业务统一成一张状态流转表后端只写一个通用的状态变更函数每一步先校验当前状态是否允许跳转到目标状态不允许就报错。预约状态我定义了四个pending待审批、approved已通过、rejected已驳回、canceled已取消。流转规则是pending 可以到 approved、rejected、canceledapproved 只能到 canceled不能直接改回 pendingrejected 原则上不允许再改只能重新发起申请。调课申请的状态更复杂一些我定义成六步submitted已提交、checked已核对原课程、allocating分配目标教室中、approved已完成、rejected已驳回、withdrawn已撤回。实际审批时管理员从 submitted 开始先确认原课程信息正确再手动或自动分配目标教室全部走完才置为 approved 并触发后续的预约变更操作。状态机的好处是省掉了大量无效判断。比如用户端撤回申请我只需要判断当前状态是不是 pending 或 submitted如果不是就直接报错“当前状态不可撤回”。这套逻辑用装饰器写成一个状态守卫函数所有接口都能复用比在每个视图函数里手写状态判断干净太多。2.4 接口与权限JWT 认证 角色装饰器后端接口的权限控制我用的是 JWT 角色装饰器。用户在小程序端登录后后端用微信登录的 code 换 openid然后签发一个带角色信息的 JWT后续所有请求都在请求头里带上 token一个装饰器搞定身份校验def role_required(min_role): def decorator(fn): wraps(fn) def wrapper(*args, **kwargs): auth_header request.headers.get(Authorization, ) token auth_header.replace(Bearer , ) try: payload jwt.decode(token, current_app.config[SECRET_KEY], algorithms[HS256]) except jwt.InvalidTokenError: return jsonify({code: 401, msg: 登录已过期}), 401 if payload.get(role, 0) min_role: return jsonify({code: 403, msg: 权限不足}), 403 g.user_id payload[user_id] g.user_role payload[role] return fn(*args, **kwargs) return wrapper return decorator这样学生端只调预约相关接口教务管理员才能调审批接口教师账号可以额外发起调课申请。接口路由我按功能分成了三组蓝图student_api、teacher_api、admin_api同一个小程序在不同角色登录后展示不同的菜单入口服务端再兜底校验一次双保险。3. 小程序端功能落地与踩坑记录3.1 预约主流程教室状态矩阵 时间段选择预约模块的首页我做成了一周教室状态矩阵纵轴是教室横轴是节次每个交叉格子用颜色标记状态绿色是空闲、黄色是待审批、红色是已占用。用户点击一个“空闲”格子就直接进入预约表单这样比传统的“选日期—选教室—再选时间”要少两步操作用户体验好很多。时间段的数据来源我做了个反向处理前端不自己拼时间列表而是访问后端提供的接口获取当天可用时间段。这个接口会根据教室的已有预约动态计算出空闲片段好处是用户看到的每一个可点击时间段都不会提交后才发现冲突。预约表单里最常被问的是用途字段我做成下拉选择班会、社团活动、自习、讲座、公开课这几类。这里有个经验不要让用户自由填写否则提交上来的用途五花八门管理员审批时根本没法快速判断该不该批。固定选项配合一个可选的补充说明输入框后续做统计报表时也能直接按类别聚合。3.2 调课申请流程原课信息回显 二次冲突校验调课申请模块的入口在教师端的课程列表里。教师选择某一门课后系统自动回显原上课时间和原教室然后教师只需要选择目标教室和目标时间再填一句调课理由提交即可。这个流程里最容易出 bug 的地方是时间选择。小程序端的时间选择控件通常返回的是“时:分”字符串后端要转成 Time 对象再做比较。我当时在调课申请提交时直接拿字符串和数据库里的时间字段比较MySQL 做了隐式转换看起来没问题但遇到下午 14:00 和早上 09:30 这类跨半天的时间段时字符串比较就会出大问题。后来我统一在提交接口里用 datetime.strptime 做显式转换才彻底解决。调课提交后后端做的第二次冲突校验非常重要。与预约不同调课申请里带了一个原预约记录 ID所以处理逻辑是事务开始先把原预约状态改为 canceling中间态再检查目标教室的时间冲突如果通过了就写入新预约并置为 approved原预约正式标记 canceled全程要么都成功要么都回滚。我为了省事一开始没做事务结果测试时出现“原课已释放、新教室没申请上”的脏数据被教务老师当场抓包后来老老实实加了 db.session.begin_nested体验才正常。3.3 最容易翻车的组件uni-datetime-picker 在 scroll-view 里的 iOS 渲染问题这个坑我印象太深了一定要单独写一节提醒做小程序开发的朋友。我当时的预约页面结构是外层一个 scroll-view 滚动容器里面嵌了一个 uni-datetime-picker 组件用于选择日期。在安卓上测试一切正常但一到 iOS 真机上点击日期选择控件后整个弹层死活弹不出来偶尔弹出来了也无法滚动选择。排查了很久之后发现问题在 iOS 的渲染机制上uniapp 的 datetime-picker 组件在 iOS 上依赖 touch 事件处理但它被嵌套在 scroll-view 中时父容器的滚动事件和子组件的点击事件会发生冲突导致组件的 touchstart 被吞掉。网上一堆类似问题多数建议是“别放在 scroll-view 里”但实际布局中我把日历区域设计在滚动容器内硬改结构成本很高。最后我的解决方案是在页面数据里加一个 showPicker 的布尔值点击日期选择区域时先把 scroll-view 的 scroll-y 动态设置为 false等选择器弹出和完成选择后再恢复为 true。实际操作中发现这个办法只解决了部分问题更稳定的是直接用 popup 组件包一层 datetime-picker让它脱离滚动容器的层级渲染。改完之后同一台 iPhone 测试弹层和滚动都恢复正常了。这段经验的核心是在 iOS 上凡是涉及弹层、选择器、浮层这类交互组件尽量用 popup 或 fixed 定位脱离文档流不要依赖组件的自动层级。uniapp 的坑基本都集中在组件层级和滚动冲突上遇到类似问题先往这个方向排查比瞎调 CSS 高效得多。3.4 几个必须处理的体验细节启动页、导航栏高度、附件保存小程序的首次加载体验直接影响使用评价。我们项目里启动页加载逻辑包括用户登录态检查、基础数据缓存拉取、角色路由配置。最开始我直接把这些逻辑全放在 App.vue 的 onLaunch 里每个页面 onLoad 时还要再等全局状态初始化结果打开页面偶尔会出现白屏或菜单闪烁。解决办法是做了一个“全局初始化状态”的集中管理启动时先展示一个自定义加载页等所有异步任务都完成之后再调用 uni.switchTab 跳转到业务首页。加载页里放了一个进度幌子不是真实进度而是几个加载阶段的状态文案比如“正在检查登录状态”“正在同步教室数据”实测这个细节让用户感知好了非常多至少不会以为小程序卡死了。顶部导航栏高度适配也必须处理。不同机型的状态栏高度不一样iPhone 有刘海屏安卓厂商各有各的状态栏高度如果写死导航栏 padding肯定会有机型错位。正确做法是先调用 uni.getSystemInfoSync 获取 statusBarHeight再调用 uni.getMenuButtonBoundingClientRect 获取胶囊按钮的位置和尺寸然后用这两个参数动态计算自定义导航栏的高度。现在 uni-app 提供了 getAppBaseInfo 等新接口但底层思路是一样的动态计算不要写死。附件保存这一块我被 wx.env.user_data_path 坑过一次。这个字段代表的是用户本地的小程序专用目录适合存放后端下载的申请附件缓存。但如果直接把后端返回的文件 URL 存到本地会遇到 iOS 上临时文件被系统清理的问题。稳妥做法是下载文件后把内容缓存到 user_data_path 下并且每次打开附件列表时检查本地文件是否存在不存在再走网络下载。这个逻辑虽然麻烦一点但离线查看附件时能明显感觉到加载速度优势。4. 联调、调试与发布避坑4.1 小程序抓包环境搭好接口问题少一半小程序开发最痛苦的是真机调试时看不到网络请求。我使用 Charles 做代理抓包重点解决两个问题一是 HTTPS 证书信任二是真机代理配置。Charles 抓 HTTPS 包的关键步骤是电脑端安装并信任 Charles 的 CA 根证书然后手机开启 HTTP 代理并安装同一个证书。这里有个细节iOS 系统版本升级后需要在“设置-通用-关于本机-证书信任设置”里手动开启根证书完全信任否则代理生效了但 HTTPS 流量仍然解密不了。Android 端 7.0 以上默认不信任用户安装的 CA 证书针对 debug 包可以在 AndroidManifest 里配置 usesCleartextTraffic 和网络安全配置来临时放行真机测 release 包则只能走内部测试通道。抓包的主要用途是排查接口请求参数和响应格式问题。预约冲突提示不准确、审批状态不变这类 bug十有八九是前端提交参数格式和后端校验不一致造成的。比如日期字段前端传的是“2024-06-01 14:00:00”后端格式化成“2024-06-01”做匹配怎么都对不上抓包一眼就能看到原始请求数据省掉大量靠猜的排查时间。4.2 一个典型的线上 Bug并发预约同一教室成功两次上线第三天我的后端接收了两条几乎同时提交的预约申请目标是同一个教室同一个时间段。我把冲突检测写在应用层也就是先用 SQLAlchemy 查了一遍没有冲突然后才插入新记录但 MySQL 默认隔离级别是可重复读两个请求同时跑起来后各自都查询不到对方的存在结果两条记录都插入成功了。这个问题的本质是“检查 写入”不是一个原子操作。解决思路有两种我采用了其中一种在后端接口层加一个 Redis 锁key 用“room_id date start end”拼出来只有拿到锁的请求才能执行冲突检测和写入。另外一个思路是直接在 MySQL 层做约束但这个场景下时间段是任意选择的没法用唯一索引来限制所以 Redis 分布式锁反而是成本最低、效果最稳的解决方案。这件事给我的教训是校园系统虽然并发量不大但“同时提交”的概率在春秋季活动高峰期并不低不能在架构上赌操作秩序。所有写操作涉及先查后写的都要默认存在并发问题要么加锁要么用数据库事务和悲观锁兜底。4.3 App 端拉起小程序uni-app 跨端配置与限制这个项目的关键词里有 App 和小程序我在 uni-app 里也接了一个 App 端拉起微信小程序的入口方便安装了 App 的教师一键跳转到微信小程序里处理审批。uni-app 里跳转小程序的接口是 uni.launchMiniProgram但前提是 App 必须集成微信 SDK并且要在 manifest.json 里配置好微信支付的 appid 等参数。实际业务中这个功能还受到微信平台限制比如只能在特定的业务场景下使用不能诱导性频繁跳转。我们只在“教师查看调课通知”页面放了一个跳转按钮语义上说得通不会触发平台风控这也是合规使用。技术上要注意的是跳转过程中需要先把待处理的调课申请 ID 缓存到 App 本地小程序那边通过场景值参数接收或者用 URL 参数传递。我在项目里采用了场景值 本地缓存双保险App 端拼接参数小程序端 onLoad 里读取场景值并跳转到对应审批页。当我第一次测试时iOS 端跳转后小程序冷启动读取场景值偶尔会丢参数调试了很久才发现是 App 端跳转前没有等待 SDK 初始化完成。加上 uni.getProvider 的判断和延时处理之后就稳定了。4.4 发布前检查清单与常用问题速查表项目发布前我把积累的坑整理成了一份检查清单这里也放出来给需要的人参考。微信公众平台方面要在开发设置里配好服务器域名request 合法域名、uploadFile 合法域名、downloadFile 合法域名。如果服务器还没上 HTTPS这个环节会被卡住所以后端部署时证书一次性配好别用 HTTP 开发完了再改。小程序端代码里凡是写死的 baseURL 都要换成分环境配置开发环境走本地局域网 IP生产环境走正式域名。我最初图省事代码里直接放了局域网 IP结果打包体验版时忘了换所有接口全部请求失败白白浪费了半个工作日。时间字段格式化问题也值得注意。如果后端返回的时间格式没有统一前端不同机型解析会有差异尤其 iOS 对“2024-06-01 14:00:00”这类带横杠的时间字符串解析有问题需要先替换成斜杠格式再 new Date。常见问题我整理了一个速查表方便排障现象可能原因处理方式小程序请求全部失败基础库正式域名未配 / 未开启 HTTPS检查 request 合法域名和证书时间显示差 8 小时前后端时区不统一全部使用 UTC 存储前端本地时区展示预约提交后提示成功但列表查不到事务未提交 / 接口返回了旧数据检查 db.session.commit 和列表缓存更新iOS 日期选择器无响应组件嵌套在 scroll-view 中用 popup 包一层或动态关闭 scroll-y用户登录态偶尔失效JWT 过期策略不合理设置较长的过期时间并实现静默续期管理员无法审批某申请状态不在可审批列表检查状态机流转规则查看当前状态值5. 个人经验与后续扩展整套系统开发下来我最深的一条体会是校园场景里的“小功能”往往比表面看起来复杂得多。教室预约背后是教室资源的时间片模型调课申请背后是审批流和状态机小程序端看起来是几个表单页面实际上要处理滚动冲突、导航栏适配、跨端兼容这些杂七杂八的细节。每个环节都不难但串在一起就是对耐心和细心的考验。后续如果要扩展我建议优先做两个方向一是消息通知体系目前的审批结果还需要用户主动刷新查看接入微信订阅消息之后每次预约状态变更都能主动推送给用户体验会有质的提升二是数据统计大屏基于预约记录做各学院使用率排行、教室空闲率分析这对教务管理者的价值甚至高于预约功能本身而且技术实现上就是几个聚合查询接口和图表页面性价比很高。最后再分享一个实操建议这种管理系统类项目开发前先去蹲一次教务老师的真实工作场景看他们怎么登记、怎么审批、怎么抱怨冲突。你写出来的字段、流程、状态都会比直接从网上下载一套所谓“校园系统源码”改出来靠谱得多。