Python微信小程序+Flask班级考勤签到系统:从设计到部署
考勤签到这件事看起来简单但真正做起来才知道坑有多深。点名浪费时间、纸质签到代签严重、数据统计靠手工录入Excel稍微大一点的班级光课后整理就是一场灾难。所以当我决定自己动手做一个Python微信小程序基于flask的班级课程考勤签到系统时核心目标就是三个学生端操作要足够快、教师端数据要有足够的可信度、后台统计要能自动完成并导出不让任何人成为人肉录表工具。这个系统选型其实很自然微信小程序做前端用户不用下载App老师发个二维码学生就能扫课间一分钟搞定签到后端用flask框架轻量、易上手、部署简单尤其适合课设、毕设以及中小型学校项目的快速落地。全文我会直接从方案设计讲到接口实现再到小程序端代码逻辑和部署踩坑完整还原这套系统从0到1的完整过程适合正在做相关毕设、课设或者想在班级管理上省点力气的老师和开发者直接参考。1. 整体方案设计与选型思路1.1 为什么是微信小程序 flask组合先聊选型。市面上考勤方案不少企业级的钉钉、飞书都自带签到功能但放在班级场景里有两个问题一是权限体系太重二是数据拿不出来。老师想按自己的课程、班级、周次维度做统计企业软件反而限制了自由度而自研系统最大的优势就是数据结构完全按课程表建模。微信小程序作为前端载体理由很硬核零安装成本。现在学生手机里可以没有校园App但微信基本人手一个。小程序扫码即用、用完即走天然契合上课签到这种高频短时交互场景。另外小程序自带wx.getLocation定位能力可以拿到经纬度做地理围栏这一点在防代签上比传统纸质签到强太多。后端选flask而不是Django、Spring Boot判断依据就三个词轻量、灵活、生态熟。flask不需要像Django那样强制绑定ORM和Admin后台签到系统本身只有用户、班级、课程、考勤记录这几张表用flask-sqlalchemy能快速建模而且flask有海量扩展JWT认证、CORS、RESTful接口都有现成方案。对Python技术栈的同学来说flask的上手曲线比Java系平缓得多网课资源也多遇到问题随便一搜就有答案。1.2 系统功能总体规划讲设计之前先明确用户角色。这个系统有三类角色学生、教师、管理员。教师端在小程序里发起签到、查看统计学生端扫码或点击签到管理员一般是教务或辅导员在Web后台看全校报表。小程序端不用做Web后台flask单独开一组路由用flask-admin或者简单模板渲染即可避免把小程序和PC端逻辑搅在一起。核心功能拆成四大模块课程管理教师创建课程、关联班级、设置上课时间与节次生成每节课的签到会话签到会话每次上课产生一个签到任务带有效期、定位范围、签到码签到执行学生在小程序端提交签到记录时间戳、经纬度、签到状态统计报表按课程、班级、时间维度汇总出勤率、迟到、缺勤记录支持导出Excel。这里最关键的设计决策是签到会话这个概念。它把课程和每一次具体签到分离避免教师重复创建签到任务每次上课打开课程列表点发起签到即可系统自动生成一个新会话。这样的好处是数据统计维度非常清晰查课程出勤率就是统计该课程下所有会话中学生签到情况查某一次旷课就是看某个会话的缺勤名单。1.3 业务流程设计——一次签到的完整旅程描述一次完整签到流程有助于理顺代码逻辑。教师端操作进入小程序 → 选择今日课程 → 点击开始签到 → 系统生成签到会话并显示4位数字签到码或二维码 → 学生端输入签到码或扫码 → 提交定位与身份信息 → 教师端实时看到签到人数。超过设定时间后教师手动结束签到系统自动将未签到者标记为缺勤。这个流程里面有个容易被忽略的点签到码一定要有时效性。许多初版设计把签到码固定为课程码结果学生提前把码发到群里整节课都能签。我这里让每次发起签到随机生成4位数字码并在后端校验会话是否在有效期内。过期后的签到请求一律拒绝同时记录提交时间防止学生通过修改手机时间来补签。2. 数据库设计与flask后端核心接口2.1 数据表设计与字段规划数据库我用MySQL 5.7原因是在课设和毕设答辩中MySQL比SQLite更有说服力而且SQLAlchemy的方言兼容性让人后期切换成本很低。核心表的字段设计直接决定后续开发的坑多坑少我这张表踩过不少坑这里把最终稳定版分享出来。学生表studentid主键自增student_no学号唯一索引登录凭证之一name姓名openid微信openid首次登录绑定后写入avatar_url头像地址class_id所属班级外键教师表teacherid、teacher_no工号、name、openid结构与学生表类似课程表courseid、course_name、teacher_id、class_idstart_week / end_week起始与结束周次day_of_week星期几上课start_time / end_time上课起止时间location上课地点用于默认签到定位签到会话表sign_sessionid、course_id、date、start_time、end_timesign_code4位随机码latitude / longitude签到允许的定位中心点radius允许签到的半径米status1进行中2已结束3已取消签到记录表sign_recordid、session_id、student_idsign_time、latitude、longitudestatus1正常2迟到3缺勤4请假distance学生提交定位与中心点的距离字段设计心得加一个sign_code字段而不是直接复用course_id这个设计很重要。它让会话与课程解耦教师可以针对某一次课单独修改签到地点或时长。另外记录表里冗余了latitude和longitude快照而不是关联会话表去查虽然有一定的空间冗余但换来的是数据不可变——学生当时在哪里签的这张表永远留着证据后续改会话信息也不影响历史记录。2.2 flask应用初始化与认证机制flask初始化部分我直接用工厂模式方便测试和部署。以下代码是基于flask 2.x、flask-sqlalchemy 3.x、flask-jwt-extended的稳定组合实测兼容性很好from flask import Flask from flask_sqlalchemy import SQLAlchemy from flask_jwt_extended import JWTManager from flask_cors import CORS import datetime db SQLAlchemy() jwt JWTManager() def create_app(): app Flask(__name__) app.config[SQLALCHEMY_DATABASE_URI] mysqlpymysql://root:passwordlocalhost/sign_system?charsetutf8mb4 app.config[SQLALCHEMY_TRACK_MODIFICATIONS] False app.config[JWT_SECRET_KEY] your-secret-key-here app.config[JWT_ACCESS_TOKEN_EXPIRES] datetime.timedelta(days7) db.init_app(app) jwt.init_app(app) CORS(app, supports_credentialsTrue) # 注册蓝图 from views.auth import auth_bp from views.course import course_bp from views.sign import sign_bp from views.statistics import stat_bp app.register_blueprint(auth_bp, url_prefix/api/auth) app.register_blueprint(course_bp, url_prefix/api/course) app.register_blueprint(sign_bp, url_prefix/api/sign) app.register_blueprint(stat_bp, url_prefix/api/stat) return app登录机制这块我采用的是微信小程序wx.login 后端 code2Session 换 openid 自定义 JWT不走官方云开发那种免鉴权方案因为要保留数据库表结构的自由度。小程序端拿到code后POST给后端后端调用微信接口换取openid然后用flask-jwt-extended生成自定义token返回前端。token有效期设为7天学生一学期登录两三次就够用了体验上比每次进入都弹登录框好很多。2.3 签到核心接口的实现逻辑签到核心接口是系统的心脏代码逻辑不复杂但校验顺序一定要设计对。我的完整校验链是token身份 → 会话状态 → 当前时间是否在有效窗口 → 定位是否在半径内 → 是否重复签到。每步都要有明确的错误码方便小程序端精准提示。from flask import request, jsonify from flask_jwt_extended import jwt_required, get_jwt_identity from models import SignSession, SignRecord, Teacher, Course from datetime import datetime from math import radians, cos, sin, asin, sqrt from . import sign_bp # 计算两个经纬度之间的距离米 def calc_distance(lat1, lng1, lat2, lng2): if None in (lat1, lng1, lat2, lng2): return None try: lat1, lng1, lat2, lng2 map(float, (lat1, lng1, lat2, lng2)) except (TypeError, ValueError): return None r 6371.0 d_lat radians(lat2 - lat1) d_lng radians(lng2 - lng1) a sin(d_lat / 2) ** 2 cos(radians(lat1)) * cos(radians(lat2)) * sin(d_lng / 2) ** 2 return round(asin(sqrt(a)) * 2 * r * 1000, 2) sign_bp.route(/do_sign, methods[POST]) jwt_required() def do_sign(): user_id get_jwt_identity() data request.get_json() session_id data.get(session_id) latitude data.get(latitude) longitude data.get(longitude) session SignSession.query.get(session_id) if not session: return jsonify({code: 404, msg: 签到会话不存在}), 404 if session.status ! 1: return jsonify({code: 4001, msg: 签到已结束}), 400 now datetime.now() if not (session.start_time now session.end_time): return jsonify({code: 4002, msg: 不在签到时间段内}), 400 distance calc_distance(latitude, longitude, session.latitude, session.longitude) if distance is None or distance session.radius: return jsonify({code: 4003, msg: f签到位置超出允许范围({distance}m)}), 400 exist SignRecord.query.filter_by(session_idsession_id, student_iduser_id).first() if exist: return jsonify({code: 4004, msg: 请勿重复签到}), 400 record SignRecord( session_idsession_id, student_iduser_id, sign_timenow, latitudelatitude, longitudelongitude, distancedistance, status1 ) db.session.add(record) db.session.commit() return jsonify({code: 0, msg: 签到成功, data: {sign_time: now.strftime(%Y-%m-%d %H:%M:%S)}})注意get_jwt_identity()返回的是字符串如果你模型中主键是Integer查询前一定要int()转换否则SQLAlchemy会报TypeError或查不到数据。这个小坑在联调时容易让人抓狂。签到状态的判定逻辑也值得一提。status字段一开始只设计成正常/缺勤两种后来发现迟到统计在实际使用中需求极高所以扩展为4种状态。判定规则是以课程start_time为基准签到时间在开课前15分钟到开课后15分钟内为正常开课后15分钟到结束签到时为迟到会话结束后未签到的自动置为缺勤。这个阈值教师可以自行配置前端留个参数入口就行。3. 小程序端关键实现与页面联动3.1 请求封装与登录态管理小程序端虽然没有复杂框架要求但良好的目录结构能让后期维护省心不少。我惯用的结构是pages/页面、utils/工具、api/接口封装、components/自定义组件。这里最值得讲的是api/request.js的封装逻辑它解决的是重复token注入和401统一处理两个问题。// api/request.js const BASE_URL https://your-domain.com/api function request(path, method GET, data {}) { return new Promise((resolve, reject) { wx.request({ url: BASE_URL path, method: method, data: data, header: { Content-Type: application/json, Authorization: Bearer wx.getStorageSync(token) }, success: (res) { if (res.data.code 0) { resolve(res.data.data) } else if (res.statusCode 401) { // token过期重新走登录流程 wx.removeStorageSync(token) wx.navigateTo({ url: /pages/login/login }) reject(res.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 }wx.login静默登录是很多人容易搞错的地方。wx.login拿到的code五分钟有效且只能换一次openid不能每次进页面都调用否则后端的code2Session会报bad code错误。我的策略是启动时检查本地是否有token有就直接进首页没有才调wx.login换取code然后POST到/api/auth/login后端完成openid绑定并返回token。如果本地token过期401拦截器会清掉token并跳登录页再重新走一遍这个流程这样用户无感知完成静默续期。3.2 顶部导航栏与加载更多列表方案小程序页面里有大量列表场景课程列表、签到记录、考勤统计每个都要做分页。热搜词里的微信小程序页面列表加载更多和微信小程序顶部导航栏高度正好是实际开发里绕不开的两个点我分别说下方案。顶部导航栏高度兼容问题在自定义导航栏时特别典型。不同机型状态栏高度不一样用固定statusBarHeight值会顶出界面。标准做法是通过wx.getWindowInfo()获取状态栏高度基础库2.20.1接口替换已废弃的wx.getSystemInfoSync胶囊按钮位置用wx.getMenuButtonBoundingClientRect()拿到导航栏总高度就是这两者之和再乘个系数兼容性测试下来几乎全机型通用。列表懒加载核心是onReachBottom页码pagehasMore标志位这一套组合拳是标配。以签到记录列表为例// pages/record/record.js Page({ data: { records: [], page: 1, pageSize: 10, hasMore: true, loading: false }, async loadRecords(reset false) { if (this.data.loading || (!reset !this.data.hasMore)) return const page reset ? 1 : this.data.page this.setData({ loading: true }) try { const res await request(/sign/records?page page size this.data.pageSize) const list res.list || [] this.setData({ records: reset ? list : this.data.records.concat(list), page: page 1, hasMore: res.has_more, loading: false }) } catch (e) { this.setData({ loading: false }) } }, onPullDownRefresh() { this.loadRecords(true).then(() wx.stopPullDownRefresh()) }, onReachBottom() { this.loadRecords() } })这里有个隐性坑加载下一页时不能简单使用page1作为请求参数必须区分reset场景。下拉刷新时要把页码重置为1并替换整个列表触底加载时才追加到现有数组末尾。否则会出现下拉刷新后第2页数据拼在第1页后面的错位问题。3.3 签到码展示与扫码签到联动教师端发起签到的页面核心交互是展示4位数字签到码同时提供一个二维码入口。数字码要做得大而醒目方便教师在讲台上展示投影二维码则方便学生就近扫码两者互为兜底。扫码这里有个重要的开发细节小程序的wx.scanCode在Android和iOS上的体验差异很大。iOS上扫码成功后会直接跳转到path对应的页面Android则可能停留在原页面不动个别机型所以不能依赖扫码后自动跳转这个隐含行为正确做法是扫码成功后拿到result扫码结果字符串我们这里定义为signcode:XXXX存入全局变量然后手动wx.navigateTo到签到页面在onLoad里读取这个参数并自动填充签到码再带上当前经纬度直接调用签到接口。二维码生成我用的是weapp-qrcode这个插件它在小程序Canvas上绘制二维码不需要后端生成图片再传回来。当时纠结过是让后端返回二维码图还是前端本地生成最后选前端生成的主要原因是本地生成省一次网络请求减少教师端在弱网环境下的加载时间教学现场网络状况不可控少一次依赖就少一分卡顿风险。关于定位授权小程序端的wx.getLocation必须在用户点击签到按钮后调用不能在onLoad里直接请求否则弹窗时机不对容易被打断而且iOS上如果用户首次拒绝授权后续需要引导去设置页手动打开。对应代码是wx.authorize({ scope: scope.userLocation, success: () { wx.getLocation({ type: gcj02, success: (res) { this.submitSign(res.latitude, res.longitude) }, fail: () wx.showToast({ title: 获取定位失败, icon: none }) }) }, fail: () { wx.showModal({ title: 提示, content: 签到需要获取位置信息请在设置中打开定位权限, confirmText: 去设置, success: (res) { if (res.confirm) wx.openSetting() } }) } })4. 部署上线与生产环境配置实录4.1 flask应用的生产化部署开发阶段用flask run跑调试服务器没问题但上线绝对不能这么干。生产环境我用的是经典的Nginx Gunicorn组合进程守护用Supervisor。选择Gunicorn而不是uWSGI的原因很朴素它纯Python实现和flask配套文档多配置项好理解uWSGI功能更猛但配置项繁多堆到后面运维成本高。签到系统的并发量撑死也就几百名学生同时请求Gunicorn完全顶得住。Gunicorn启动命令示例gunicorn -w 4 -b 127.0.0.1:8000 manage:app-w 4是worker进程数不是随便拍的。最佳实践是2 * CPU核心数 1我用的是2核4G的云服务器4个worker刚好。worker类型默认是sync如果你的接口里有长轮询或SSE的需求再换gevent我们这全是短请求没必要。Nginx配置里不要忘了三件事静态文件代理、HTTPS证书配置、请求大小限制。微信小程序上线强制要求HTTPS证书用免费的就行Nginx下配置很成熟。请求大小限制主要防大图上传签到系统里如果以后加请假拍照功能client_max_body_size要相应调大我的一般设10m。server { listen 443 ssl; server_name your-domain.com; ssl_certificate /etc/nginx/ssl/your-domain.pem; ssl_certificate_key /etc/nginx/ssl/your-domain.key; location / { proxy_pass http://127.0.0.1:8000; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } }后端的SECRET_KEY、数据库密码这些敏感信息建议用环境变量注入而不是硬编码在配置文件里。项目开源出去的时候一定要检查有没有把config.py一起提交到仓库。这个坑我在网上见过太多人踩一旦密钥泄露JWT token就能被伪造等于系统大门对所有人敞开。4.2 微信小程序端上线前检查小程序端提交审核前有个体验版阶段。我在热词列表里看到微信开发者工具里的小程序怎么发给其他人试用这个需求其实很常见做法也很简单开发者工具右上角版本管理 → 上传然后在微信公众平台后台版本管理里把上传的版本设为体验版注意开启体验成员权限。切换到体验版需要扫码而且只有体验成员才能打开并不是谁拿到二维码都能看这个权限要先在后台配好。审核正式版之前有几个坑几乎必踩域名合法性request合法域名必须在公众平台后台配置不在列表里的域名一律请求失败且微信不允许端口号进配置必须走默认443端口。调试阶段可以用开发者工具不校验合法域名选项但真机预览就会失败这已经骗过无数人了用户隐私保护指引在后台设置 → 服务内容声明里主动声明收集位置信息和微信昵称等数据否则审核会被打回且新版基础库在未声明的情况下直接限制部分API的调用地理位置接口权限wx.getLocation需要在公众平台接口设置里手动开通不开通时间再长也不会生效。4.3 数据升级与并发安全上线跑了一周后我遇到一个并发隐患两个学生同时点签到后端两个请求同时查询签到记录都发现不存在然后同时插入导致同一条记录插入两次。虽然我设计了exist检查但在高并发下check-then-insert存在竞态条件。解决方案有两种一是在sign_record表上加(session_id, student_id)联合唯一索引数据库层面兜底二是把插入逻辑改为INSERT ... ON DUPLICATE KEY UPDATE的幂等写法。我两个方案都上了前者保底后者通过SQLAlchemy的mysql insert方言实现双保险后数据脏读率降为零。from sqlalchemy.dialects.mysql import insert as mysql_insert stmt mysql_insert(SignRecord).values( session_idsession_id, student_iduser_id, sign_timenow, latitudelatitude, longitudelongitude, distancedistance, status1 ).on_duplicate_key_update(sign_timenow) db.session.execute(stmt) db.session.commit()查询测速上给sign_record表的session_id和student_id建联合索引非常必要。一开始我只在id上有主键索引统计10个会话的出勤时明显感觉到慢一两秒加联合索引后查询时间降到几十毫秒。这个问题在数据量只有几百条时无感等班级多了、数据量上到几万条再补索引就晚了。5. 常见问题排查与避坑实录5.1 小程序侧高发问题速查我把自己和同学做类似项目时踩过的坑整理成一个速查表按现象 → 原因的方式记录下来遇到问题可以直接对照排查。现象根本原因解决方案真机上wx.request直接fail域名未在公众平台配置为合法域名后台添加并校验注意不能带端口和路径扫码进入签到页但签到码为空扫码结果未保存到全局再跳转页面重新启动后onLoad拿不到参数在onShow里检查全局变量而不是onLoad后端报cannot import name db循环引用models.py和app.py互相导入用create_app工厂模式db单独在extensions.py中定义定位偏差几百米wx.getLocation坐标系是gcj02后端用高德地图类型解析坐标系不一致统一用gcj02高德/微信都用它不要混用GPS原始坐标学生修改手机时间后签到时间异常完全信任客户端时间戳时间以服务器为准客户端时间只做展示退出小程序再进签到页面状态丢失小程序页面被销毁状态未持久化关键状态如当前sessionId存入wx.setStorageSync5.2 flask后端常见报错与调试方法后端调试我推荐一个很笨但很有效的思路先把所有接口用Postman测通再连小程序。因为小程序端的报错信息被拦截了一层很多问题很难分清是前端还是后端导致的。我实际开发中Postman先能通再回小程序联调至少能省下50%的调试时间。后端最常见的坑是时区问题。MySQL默认存的是CSTChina Standard Time而Python的datetime.now()在服务器上可能取的是UTC时间如果服务器时区没设置正确存进数据库的时间会比实际时间晚8小时。解法是宿主机和容器都明确设置时区# 在/etc/profile里追加 export TZAsia/Shanghai或者更推荐的做法数据库连接串里加?serverTimezoneAsia/Shanghai参数两处都配好保证从取到存整个链路都是同一时区。这一点在小程序端展示迟到时间时非常关键差八小时会直接导致迟到记录全错。还有一个flask开发环境特有的坑debug模式下app.run(debugTrue)会启动Werkzeug的增量重载器导致db.create_all()被执行两次。如果你的初始化代码写在模块顶层第一次执行创建表成功第二次因为表已存在就报错。解决方法是把建表逻辑放进if __name__ __main__块内或者用with app.app_context():包裹。5.3 审核与发布环节的提醒小程序审核是个不可控环节我能分享的经验是在教育类目下涉及校园场景的小程序通常需要提供相关的资质证明个人主体开发者如果拿不到学校授权文件类目选择建议用工具 → 效率避免触发教育资质审核。这是很多课设团队的共同经验后台需要上传《校园应用资质证明》或《组织机构代码证》之类的材料个人做课设的化建议先咨询平台在线客服确认最新要求再提审。另外要注意微信的隐私政策最近收得越来越紧如果我的小程序里要采集学生手机号必须使用微信提供的手机号快速验证组件不能自己加输入框收集——后者不仅审核过不去还有数据合规风险。而签到系统其实不需要手机号用openid做唯一标识就够了可以避开这个敏感点。6. 从项目到实用扩展方向与个人心得这个系统做完之后我发现它的意义远不止课设答辩其实已经具备了一个小规模产品的最小形态。如果后续要继续演进我建议按以下节奏迭代第一优先级是补请假审批流。现在的状态机里缺勤是硬性的学生确实生病去不了、提前跟老师请假但系统里没有入口导致统计数据不准。加一张请假表请假审批通过之后跟签到会话关联缺勤名单自动剔除请假学生统计才真正有参考价值。第二优先级是周报自动推送。现在签到数据走后台看老师经常忘记主动查看。可以加一个定时任务celery或者flask-apscheduler每周日晚汇总本周各课程出勤率通过订阅消息推送到教师微信。订阅消息是小程序推送的官方方案一次性订阅只能发一条但应对周报这种低频提醒刚合适。第三个方向是AI辅助分析。签到数据积累一个学期后可以跑一些简单的关联分析哪些时间段到课率最低、哪些课程缺勤聚群明显甚至预测学生的挂科风险。这些在毕业设计里都是很好的加分项数据基础已经有了剩下就是套用现成的机器学习库跑模型。最后讲点个人真实体会。做这个项目的过程中最大的挫败感并不是来自代码本身而是反复修改需求——本来以为签到就是打个卡做完了才发现老师真正关心的是谁没来、为什么没来、持续多久。所以如果现在让我重新设计这套系统我第一步会先去访谈三个真实的老师把他们最想看到的统计报表画成原型再做表结构。技术选型反而简单flask和微信小程序这对组合在校园这种轻量级、高复用、权限简单的场景里已经是效率很高的方案了。如果你正在做类似的项目希望这篇记录能帮你少走几条弯路。