ARTICLE DETAIL

资讯详情

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

Python+Flask+微信小程序:图书馆座位签到与占座管理系统实战

Python+Flask+微信小程序:图书馆座位签到与占座管理系统实战 去年夏天我们学校图书馆爆发过一场非常典型的占座风波考前一个月开馆半小时二楼自习区的座位全部被书包和水杯占领真正坐下来看书的人不到一半。管理员在桌上贴了一周的“人走带物”纸条效果跟没贴一样。这事的本质不是学生的自觉性问题而是公共资源的使用状态完全没有被公开——座位空不空、谁在用、离开多久了全靠肉眼判断和碰巧遇上。我当时就决定做一个基于 Python Flask 微信小程序的图书馆座位签到离开管理系统每个座位贴上二维码任何人扫一下就能看到实时状态学习期间扫码签到临时离开点暂离走的时候点离开释放。系统自动处理超时未归的座位把规则从“人的提醒”变成“代码的执行”。这套逻辑跑通之后占座纠纷直接少了九成。这篇文章会把整个系统从需求拆解、数据库建模、后端接口、小程序端实现到线上部署踩坑完整过一遍。如果你正准备做课程设计或毕设或者你们图书馆、自习室想用最低成本解决占座问题可以参考这个思路直接落地。项目本身不复杂但涉及的小细节特别多很多坑是我实际跑起来才踩出来的。1. 占座问题的本质与功能边界设计1.1 座位冲突的核心不是素质而是状态不透明占座纠纷频发表面看是素质问题根子上是两件事一是状态不透明二是没有可靠的释放机制。你扫一眼整排桌子只能看到水杯和书包判断不了主人是去厕所了、去吃饭了还是回宿舍了。制度上想让使用者主动释放座位又缺乏一个能强制执行的落点。这套系统要做的就是把两个缺口补上。状态不透明就用小程序做实时公开看板——所有座位的状态实时同步到每个人的手机里谁在哪个座、什么时候进来的、暂离还剩几分钟全部可查。释放不可靠就用状态机任务链把“暂离超时自动释放”“管理员强放”做成后台兜底。你不需要跟任何人解释规则代码在后台自动执行。一次最完整的座位生命周期是这样跑的学生到图书馆打开小程序微信身份自动登录。扫所在座位右上角的二维码座位状态从“空闲”变为“使用中”系统记录签到时间。中途想去接水、上厕所、吃饭点“暂离”座位变为“暂离中”保留权益15分钟。回到座位点“返回”状态回到“使用中”暂离倒计时清零。离开图书馆点“离开释放”座位变为“空闲”记录本次总使用时长。暂离超过15分钟未返回系统自动释放座位对使用人记一次违规。这套流程的关键在于“离开”必须是明确动作而不是系统猜测。我设计时刻意不放摄像头人像检测之类的方案成本和隐私问题都太大。人体感应这类东西以后可以扩展第一版坚决不碰。1.2 为什么坚决不做预约排号第一次讨论需求时好几个同学强烈建议加预约功能提前一天晚上选座第二天到馆直接坐。被我否了。原因很简单预约制会催生代抢和黄牛而且馆方根本无法验证“预约了的人到底来没来”这会把占座问题从实体世界转移到虚拟世界反而更难治理。图书馆座位的真实场景是“到馆即用、人走释放”不是“预留专用”。所以系统的功能面就锁定在签到—暂离—离开这三板斧上外加管理员强放和违规计数形成一个自洽的小闭环。需求边界清晰开发量可控运营规则也讲得清。我见过太多校园系统因为塞了预约、积分、排行榜最终烂尾的。功能少才敢上线才敢说稳定。2. 技术选型复盘Python Flask 微信小程序这一组合的取舍技术选型不是越新越好而是看谁的生态、资料、部署成本最匹配当前场景。校园场景的特点是并发量不大几百人同时用已经算高峰需求变化快老师今天要加个字段明天要调参数开发周期短从立项到可用通常只有几周。基于这三个特点我选了 Python Flask 微信小程序这条最稳的路。2.1 Flask 与 Django、FastAPI 的对比当时身边人给我推荐过至少三个框架我列了一个简单的对比表维度FlaskDjangoFastAPI轻量程度高一个 app.py 就能跑低框架自带头重脚轻高但生态年轻学习曲线缓装完就能干活陡概念多、约定多中需要理解异步模型适合场景中小型 API 简单页面大型后台管理系统高性能接口服务中文资料与轮子非常多多在增长但还不够迁移改造成本低路由和业务松耦合高ORM 和 Admin 深度绑定中异步背景下配套要重配我选 Flask 的核心原因是自由。Django 自带 Admin 后台和完整 ORM听起来省事但一旦业务模型不是典型的“文章 / 用户 / 增删改查”那些默认配置全是束缚。FastAPI 的自动文档和异步性能确实香但配合微信小程序这种业务异步优势用不上生态里现成的组件反而要自己拼。在这个项目里Flask 半小时就能把所有路由码完Django 得先想清楚项目和 app 怎么划分FastAPI 还得让队友先理解异步会话那一套。开发环境也简单Python 3.8 以上直接 pip 安装网上教程多到不用我重复。2.2 微信小程序相比 App 和 H5 的不可替代性前端为什么不是 App因为要让用户为了扫码看座位去下载一个 App这个转化成本在校园里几乎不可能完成。为什么不是 H5因为 H5 网页没法直接调用微信原生扫码能力要么引导用户打开相机拍照再用图像识别解析二维码要么嵌一个第三方扫码库识别率和体验都差一档。小程序正好把两头都占了免安装、微信内直接打开、原生 wx.scanCode 扫码体验和原生 App 没差别开发成本却低得多。还有一个隐性优势微信支付和订阅消息都是现成的。虽然这个项目用不到支付但以后想加“进馆提醒”或者“座位被释放通知”小程序可以直接调订阅消息接口App 和 H5 都做不到这么顺。2.3 整体架构与请求链路架构上就是一条相对简单的线微信小程序端负责扫码、展示和交互Flask 提供 JSON API 和极简 Web 后台SQLAlchemy 做 ORM开发期用 SQLite线上换 MySQLAPScheduler 挂在 Flask 进程里做超时扫描调度。用户请求走的链路是小程序 wx.request 发起 HTTPS 请求Flask 路由校验 token 和参数SQLAlchemy 操作数据库返回 JSON小程序渲染页面。这条链路上每一步都有成熟组件不需要引入 Redis、Celery 之类的重型依赖。第一版的目标是先让业务闭环性能问题等真出现再处理别为了想象中的高并发提前把架构搞复杂。后面如果真有性能需求优先考虑把座位状态缓存到 Redis 里但那是后话。3. 数据库建模三张表和一套状态机数据库是整个系统里最不能返工的部分。我第一版改动最多的就是表结构到第三版才稳定下来。核心表就三张user_info用户、seat_info座位、seat_usage_log使用记录。违规计数直接放在 user_info 上不需要单独的违规表因为现阶段只需要统计次数不需要追溯每次违规的详情。3.1 用户表openid 和学号的双轨制用户身份有个双轨问题微信侧的身份标识是 openid馆方核验身份需要学号。所以 user_info 里两个字段都要有。openid 在登录时自动获取学号需要用户首次使用的时候自己填写管理员可以在后台核对。千万别只存学号不存 openid否则用户换微信号就完全找不回来了也别只存 openid 不存学号否则管理员线下根本认不出这个人是谁。建表 SQL 大致是这样CREATE TABLE user_info ( id INTEGER PRIMARY KEY AUTOINCREMENT, openid VARCHAR(64) UNIQUE NOT NULL, student_no VARCHAR(20) UNIQUE, nickname VARCHAR(64), violation_count INTEGER DEFAULT 0, created_at DATETIME );3.2 座位表把物理座位映射成一条可管理的数据seat_info 的字段设计要考虑两件事一是让前端展示方便按楼栋、楼层、区域筛选二是让状态机运转有据。status 我用字符串不用枚举类型原因很实际SQLite 不支持枚举而且开发期调试时你看一眼数据就知道这个座位现在是什么状态不需要去查枚举码表。current_openid 存当前正在使用的人checked_at 存签到时间temp_left_at 存暂离开始时间这三个字段搭配状态字段就能支撑所有接口判断。CREATE TABLE seat_info ( seat_id INTEGER PRIMARY KEY AUTOINCREMENT, building VARCHAR(32), floor VARCHAR(8), room VARCHAR(32), seat_no VARCHAR(16), status VARCHAR(16) DEFAULT free, -- free / occupied / temp_left / disabled current_openid VARCHAR(64), checked_at DATETIME, temp_left_at DATETIME, UNIQUE (building, floor, room, seat_no) ); CREATE INDEX idx_seat_status ON seat_info(status);座位状态我定义了四个free 空闲、occupied 使用中、temp_left 暂离中、disabled 停用。disabled 用于管理员临时锁定某个故障座位业务上不允许用户签到。3.3 使用记录表状态字段之外的时序账本如果所有信息都写在 seat_info 的 current_openid 上历史就丢了。所以每次签到流程都要往 seat_usage_log 写一条记录暂离、返回、离开都往这条记录里填时间戳。这张表后期能出非常多的价值统计每天各时段占用率、识别长期霸座的座位、导出违规名单、分析图书馆高峰期。seat_info 是“当前快照”seat_usage_log 才是“完整账本”。CREATE TABLE seat_usage_log ( id INTEGER PRIMARY KEY AUTOINCREMENT, openid VARCHAR(64) NOT NULL, seat_id INTEGER NOT NULL, check_in_time DATETIME, temp_left_time DATETIME, temp_back_time DATETIME, check_out_time DATETIME, auto_released INTEGER DEFAULT 0, created_at DATETIME ); CREATE INDEX idx_log_openid ON seat_usage_log(openid); CREATE INDEX idx_log_seat_time ON seat_usage_log(seat_id, check_in_time);3.4 状态机的落库约束状态机的所有合法流转我会在代码注释里写死接口层只允许按箭头走free - occupied - temp_left - occupied - free。所有非法流转在接口第一层就拦截。为什么要强调这个因为两个管理员手工改数据库、或者后续加接口的同事不懂约束状态一乱前端全部跟着错。我在项目根目录加了一个 MARKDOWN 文档专门画状态流转图所有涉及状态改动的接口都对照它检查。规则放在一个地方写清楚后面维护少吵架。4. 后端核心代码登录、签到、暂离、离开与超时释放4.1 登录接口用 code 换 openid小程序端 wx.login 拿到的 code 是一次性凭证有效期五分钟用一次就失效。后端要拿这个 code再加上小程序后台的 appid 和 secret去微信接口换 openid 和 session_key。这个流程没有用户密码传统登录那套完全用不上。app.post(/api/login) def login(): code request.json.get(code) appid current_app.config[WX_APPID] secret current_app.config[WX_SECRET] resp requests.get( https://api.weixin.qq.com/sns/jscode2session, params{ appid: appid, secret: secret, js_code: code, grant_type: authorization_code, }, timeout5 ).json() openid resp.get(openid) if not openid: return jsonify({code: 1, msg: 微信登录失败}), 401 user db.session.execute( text(SELECT id FROM user_info WHERE openid :oid), {oid: openid} ).first() if not user: db.session.execute( text(INSERT INTO user_info (openid, nickname, created_at) VALUES (:oid, :nick, :now)), {oid: openid, nick: 微信用户, now: datetime.now()} ) db.session.commit() return jsonify({code: 0, openid: openid})这里有一个必须强调的生产安全点secret 绝对不能出现在小程序前端代码里也绝对不能写进小程序的配置文件中。只要它出现在前端就等于把你的小程序后台凭证送给了所有人。4.2 签到接口的原子性两个同学同时扫码谁赢签到是整个系统的核心动作也是最容易出并发 bug 的地方。我第一版实现是先查再改先 SELECT 座位状态如果是 free 再 UPDATE 成 occupied。这个方案在并发压测下必挂。两个同学几乎同时扫同一个空座位都查到 free然后都执行 UPDATE后写的人把前面的人覆盖掉。最终 seat 上只有一个人的 openid但 seat_usage_log 里两个人各有一条记录前端两个人也都提示签到成功乱成一团。正确做法是直接执行条件更新靠 UPDATE 影响行数来判断是否成功。这就是数据库层面的“原子比较并交换”类似乐观锁def check_in(openid, seat_id): now datetime.now() conn db.session.connection() trans conn.begin() try: result conn.execute( text( UPDATE seat_info SET status occupied, current_openid :oid, checked_at :now, temp_left_at NULL WHERE seat_id :sid AND status free ), {oid: openid, sid: seat_id, now: now} ) if result.rowcount ! 1: trans.rollback() return {code: 1, msg: 座位已被占用或不存在} conn.execute( text( INSERT INTO seat_usage_log (openid, seat_id, check_in_time) VALUES (:oid, :sid, :now) ), {oid: openid, sid: seat_id, now: now} ) trans.commit() return {code: 0, msg: 签到成功} except Exception: trans.rollback() raiseUPDATE 语句里的 WHERE statusfree 就是关键。两个请求同时进来数据库的锁机制会让它们串行执行第一个请求把状态改成 occupied 并返回 rowcount1第二个请求再执行时已经匹配不到 free 状态rowcount0直接判定失败。UPDATE 和 INSERT 包在同一个事务里保证座位归属和使用记录永远一致。这也是为什么 SQLite 在开发期够用单文件数据库的锁机制虽然简单但对这种单行条件更新完全够用。上线换 MySQL 时这条 SQL 逻辑一行不用改。4.3 暂离、返回、离开先校验归属再改状态这三个接口的核心都是“先校验归属再改状态”。我封装了一个通用函数专门查当前用户有没有未关闭的使用记录def current_usage(openid): row db.session.execute( text( SELECT * FROM seat_usage_log WHERE openid :oid AND check_out_time IS NULL ORDER BY id DESC LIMIT 1 ), {oid: openid} ).first() return row在归属校验的基础上每个接口再判断座位状态暂离要求 seat_info 的 status 必须是 occupied而且 current_openid 必须是本人。满足条件后把 status 改成 temp_lefttemp_left_at 写成当前时间。返回要求 status 必须是 temp_left。满足条件后把 status 改回 occupiedtemp_left_at 置为 NULL同时在 usage_log 里把 temp_left_time 和 temp_back_time 填上。离开要求 status 是 occupied 或 temp_left 都可以。为什么放宽因为用户点了暂离去餐厅吃饭15 分钟已经过去但扫描任务还没跑到那一行他直接走出图书馆了。如果离开接口只认 occupied那这个座位要一直等到管理员强放才能释放对后面找座的同学不公平。所以离开接口两个状态都认只要归属人是你就给你释放。离开接口执行时要把 seat_info 释放成 free、清空 current_openid同时把 usage_log 的 check_out_time 填上当前时间。这一步也要包在事务里避免出现座位释放了但记录没封口的情况。4.4 超时释放APScheduler 定时兜底暂离超时释放靠的是定时任务。我用 APScheduler 每分钟扫描一次找出暂离超过 15 分钟且没有被用户主动返回的座位自动释放并记一次违规from apscheduler.schedulers.background import BackgroundScheduler scheduler BackgroundScheduler() scheduler.add_job(release_expired, cron, minute*) def release_expired(): now datetime.now() limit now - timedelta(minutes15) expired db.session.execute( text( SELECT seat_id, current_openid FROM seat_info WHERE status temp_left AND temp_left_at IS NOT NULL AND temp_left_at :limit ), {limit: limit} ).fetchall() for seat_id, openid in expired: db.session.execute( text( UPDATE seat_usage_log SET check_out_time :now, auto_released 1 WHERE seat_id :sid AND openid :oid AND check_out_time IS NULL ), {now: now, sid: seat_id, oid: openid} ) db.session.execute( text( UPDATE seat_info SET status free, current_openid NULL, temp_left_at NULL, checked_at NULL WHERE seat_id :sid AND status temp_left ), {sid: seat_id} ) db.session.execute( text( UPDATE user_info SET violation_count violation_count 1 WHERE openid :oid ), {oid: openid} ) db.session.commit()这里有三点很容易踩一是扫描条件必须带 statustemp_left否则返回座位的人如果代码里有 bug 没清掉 temp_left_at会被误释放二是释放座位和更新违规计数必须在同一个事务里否则会出现座位被释放了但违规没记上三是开发调试时不要用 app.run(debugTrue) 跑这个任务Werkzeug 的 reloader 会把子进程复制一份定时任务跟着跑两遍违规计数直接翻倍。这个坑我后面会详细说。5. 小程序端实现要点扫码、页面栈和生命周期5.1 登录态处理与自定义 token小程序每次启动都调 wx.login 会浪费微信接口配额更合理的做法是登录成功后后端返回一个自定义 token小程序存到 storage之后的请求都带这个 token。token 可以直接用 itsdangerous 生成带过期时间的签名串也可以自己拼一个简单的 uuid 加有效期存到服务端。这里一个容易忽略的点openid 只作为后端内部身份不要直接暴露给前端长期保存。前端保存自定义 token接口里用 token 换身份这样就算有人截到了 token也只能在有效期内使用不会永久泄露用户身份。虽然校园场景威胁不大但养成好习惯很重要。5.2 wx.scanCode 扫码签到的实现扫码是这套系统的入口动作。二维码内容我建议不要只放一个裸的 seat_id而是放一段带协议前缀的文本比如libseat://checkin?seat_id12。这样即便别人用手机扫了也能从前缀知道这是个图书馆座位码而不是一串无意义的数字。小程序端解析时用正则把参数抠出来wx.scanCode({ onlyFromCamera: true, success(res) { const result res.result || const match result.match(/seat_id(\d)/) if (!match) { wx.showToast({ title: 无效的座位码, icon: none }) return } const seatId Number(match[1]) request(/api/check_in, { seat_id: seatId }, POST).then(() { wx.showToast({ title: 签到成功, icon: success }) }) } })onlyFromCamera 设为 true 是有意为之避免用户从相册选择旧二维码。旧码可能对应已变更的座位扫了容易出问题。5.3 用户离开小程序的监听策略关于“微信小程序如何监听用户离开小程序”这是被问得最多的高频问题。现实是小程序没有任何一个可靠的“用户彻底关闭”事件。onUnload 只在销毁当前页面时触发用户从最近任务列表里划掉小程序、或者手机系统直接回收小程序进程你的回调代码根本不会执行。所以千万别把座位释放逻辑放在 onUnload 里。我的策略是三层兜底首页放置一个醒目的“离开释放”按钮把主动释放作为唯一标准动作用户习惯是培养出来的。onHide 不做任何释放逻辑因为切后台可能是去回微信消息不能误放座位。后端加心跳上报onShow 时把当前用户 id 和座位状态发给后端管理员后台可以一键强放超过 4 小时仍处于占用状态的座位。这条策略保障了一个核心原则宁可让座位多占一会儿也不要因为误判把正在学习的用户座位放了。多占可以通过管理员强放来修正误放造成的用户流失是灾难性的。5.4 自定义导航栏和列表分页顶部导航栏高度在不同机型上不一样很多新手直接用固定值写死结果在 iPhone 和小米上页面错位。正确做法是动态计算const { statusBarHeight } wx.getSystemInfoSync() const menu wx.getMenuButtonBoundingClientRect() const navHeight menu.height (menu.top - statusBarHeight) * 2 statusBarHeight this.setData({ statusBarHeight, navHeight })座位列表的分页我建议用 cursor 方式而不是传统的 limit offset。offset 在数据量变大之后会越来越慢而 cursor 方式只需要记住上一次拿到的最后一条 id后端查询时用WHERE id cursor ORDER BY id DESC LIMIT 20性能和稳定性都好很多。小程序里下拉加载更多时把上一次的 cursor 带在请求参数里就行。6. 实测中踩过的坑与修复记录6.1 两个同学同时扫码座位被双开第一版上线第一天就遇到了。两个同学同时扫同一个空座位后端先查再改的逻辑下两个人都收到“签到成功”的提示但座位实际只归属了后写完的那个人。被覆盖的同学坐了几分钟后又被真正持座的同学赶走纠纷直接在现场爆发。这就是我在 4.2 讲的条件更新要解决的问题。改成 UPDATE 条件匹配后等第二个请求进来时WHERE statusfree 已经匹配不到直接返回“座位已被占用”从根源上堵住了双开。修复之后我专门写了个脚本模拟 50 个并发请求同抢一个座位结果始终只有一个成功这才放心。6.2 暂离超时被误释放暂离功能上线后收到一个诡异的投诉有同学点了暂离去接水回来发现座位被释放了时间才过去两分钟。查日志发现定时任务把这人判定为超时但座位明明显示暂离状态。问题出在代码里我第一版更新 seat_info 释放座位时WHERE 条件只写了 seat_id没带 statustemp_left。定时任务把这个人的暂离记录释放了但同一时刻用户点“返回”成功改回了 occupied两条 SQL 交错执行后执行的释放语句把已经返回的用户给清空了。修复方案就是在释放 SQL 里强制加上 statustemp_left 条件只要状态已经被改回去释放就匹配不到。这条误释放问题的根因是“检查时点和更新时点不一致”。定时任务先 SELECT 查出超时列表再 UPDATE 释放中间隔着时间窗口。所有类似的定时任务UPDATE 条件都必须带上状态字段做二次校验。6.3 服务器时区不一致导致定时任务错乱开发机上跑得好好的部署到云服务器后暂离释放的时间全乱了。排查发现是时区问题本地是 UTC8云服务器默认 UTC。datetime.now() 在不同机器上差 8 小时导致定时任务在本地看起来正常的间隔在服务器上完全错位。我的修复方案是统一时区。在 Flask 启动文件最前面设置环境变量import os import time os.environ[TZ] Asia/Shanghai time.tzset()同时 APScheduler 初始化时也指定时区scheduler BackgroundScheduler({apscheduler.timezone: Asia/Shanghai})这样所有时间相关逻辑都在同一套时钟体系下。注意 Windows 上没有 time.tzset()这也是我最终把开发环境迁到 Linux 的原因。如果你也在 Windows 上做这类项目建议提前用 Docker 跑 Linux 容器省得后面踩时区坑。6.4 上线部署的证书和域名问题小程序正式版要求 request 合法域名必须是 HTTPS 且完成了备案。开发阶段可以在微信开发者工具里勾选“不校验合法域名”真机预览则要手机和电脑在同一局域网把接口地址改成电脑的局域网 IP再把 IP 加进 request 合法域名临时测试。上线时我用了云服务器加 Nginx 反代加 Gunicorn 托管 Flask证书直接申请云平台免费证书。这一步最琐碎却是从“能开发”到“能上线”的必经之路。我第一版在本地跑得欢一上真机就发现请求被域名白名单拦住排查了半天。建议至少留出两天专门处理上线链路别卡在最后一步。这套系统目前跑了两个学期中途改了三个版本最大的体会是校园工具类项目的成败往往不取决于技术复杂度而取决于规则对用户够不够友好、兜底机制够不够稳。再说两个小技巧座位二维码打印时用哑光贴纸别用高光材质反光会导致扫码识别率骤降打印尺寸至少 3 厘米见方贴在桌面右上角管理员清场时也方便扫。如果后续想做深可以加一个管理员驾驶舱大屏把 seat_usage_log 表里的数据按时段聚合出来座位利用率一目了然。那是另一个有意思的话题但先把签到离开这套闭环跑稳比什么花活都强。
返回列表