
带过班或者上过课的人都知道每节课点名这事儿看起来简单真做起来全是心塞。四五十人的班级一一点名要花三五分钟喊“到”的时候还有可能替人应声到了期末统计出勤率翻着纸质点名册一个个数算错两三个太正常了。我自己也经历过这个阶段所以去年做了一套基于Python Flask后端 微信小程序的班级课程考勤签到系统把开课、签到、统计整个链路理顺了。这篇文章就把这套系统的设计和落地过程完整讲一遍适合正在考虑做考勤类小程序、或者想用Flask快速支撑一个微信小程序业务后端的同学参考。1. 考勤这个场景到底难在哪先把需求拆干净很多人在做考勤系统之前第一反应是“不就是个打卡嘛做个页面记录一下时间就行”。真动手之后才会发现考勤系统最麻烦的部分根本不在打卡本身而在“确认这个人确实来了”和“数据能方便统计”这两件事上。1.1 用户角色与核心流程老师、学生、管理员三视角一套班级课程考勤系统表面上只有“学生签到”一个动作实际上牵扯三类角色。教师端需要创建课程、发布一次签到、实时看到已签到学生名单、查看某门课的整体出勤率。学生端看到自己选了的课程列表、进入课程后点击签到、查看自己的出勤记录。系统管理员很多时候就是老师自己兼任维护学生名单、导入选课关系、处理异常签到数据。核心流程说起来也简单老师创建一个课程比如“Python程序设计”每周二上午三四节然后把学生批量导入或由学生自助绑定上课时老师在小程序里点“发起签到”学生端立即看到可签到状态点击签到并授权定位后端记录时间、位置、状态课后老师可以在后台或小程序里按课程、按日期、按学生维度导出统计。1.2 真正让考勤变复杂的三件事第一是防代签。一个学生可以把自己的微信给同学或者把自己的小程序二维码截图发出去让别人帮忙扫。纯记录时间的方式完全防不住这种操作必须组合时间窗口、定位、动态码等手段。第二是数据维度的组织。一个学生一学期选多门课一门课有多节课每节课有多次签到活动。如果没有把“课程、课时、学生、签到”拆成独立的数据结构后期统计一定会混乱到崩溃。第三是异常处理。学生手机定位不准、老师晚发布了签到、学生忘记签到了需要补签这些真实场景里几乎每周都会发生。考勤系统必须给老师提供“手动补签”和“撤销签到”的能力否则系统只会给老师添堵。我见过不少考勤项目最后做不下去不是因为代码写不出来而是因为把需求想得太简单做出来一个只能“点按钮记时间”的玩具老师用两周就放弃了。所以这块我把需求拆解放在第一位后面所有设计都围绕这三件事展开。2. 技术选型Flask 微信小程序这套组合的取舍逻辑技术选型这事没有绝对的对错只有合不合适。我当时评估过好几套方案最后落地的是 Flask 微信小程序原生框架 MySQL这套组合在校园场景里非常务实。2.1 为什么后端用 Flask 而不是 Django 或 Node.jsDjango 功能完整自带 Admin 后台和 ORM但它的“重”在这个项目里反而是负担。考勤系统的接口数量并不多核心业务接口加起来不到二十个用 Flask 写起来非常轻快路由即服务一段代码一个接口维护成本很低。同样是 Python 系Flask 的学习曲线也更友好。带的学生如果想自己读懂代码Flask 的上下文比 Django 的 settings 配置好理解得多。而且 Flask 配合 SQLAlchemy 做 ORM数据库操作并不比 Django 差之后要换数据库也只要改配置。Node.js 当然也能做但考虑到这个系统的维护者是老师和学生不是专业前端团队Python 的通用性在这里优势明显。Flask 部署也简单Gunicorn 一拉Nginx 反代一下就能上线后面我专门说部署的事。2.2 微信小程序的学生端入口优势免下载、即点即用学生端选微信小程序理由太直接了校园里没有人没装微信小程序不用下载 App也不用注册账号微信登录直接拿 openid 就是用户身份。相比 H5 网页小程序的地图定位、位置授权、拍照扫码这些能力都是封装好的不用为了兼容不同浏览器费劲。相比原生 App小程序免去了安装和版本更新的成本老师发一个码学生扫一下就能进课程签到。还有一个很现实的原因微信小程序有审核机制但如果只是面向校内师生使用属于“仅供校内使用”的类目个人开发者也能申请材料准备相对简单。2.3 整体架构HTTP JSON小程序和 Flask 如何沟通这套系统没有用什么复杂的通信协议就是最标准的 HTTP 请求 JSON 数据。小程序端通过wx.request把请求发到 Flask 后端后端处理完返回 JSON。登录态用 token 维护——小程序登录拿到 openid 后后端生成一个带过期时间的 token小程序把它存在本地 Storage之后每次请求都带上。小程序wx.request → HTTPS 请求 → Nginx (443 端口) → Gunicorn (127.0.0.1:8000) → Flask 应用 → MySQL这里有个关键点小程序正式环境必须用 HTTPS 域名而且这个域名必须在小程序后台配置白名单否则请求直接 fail。我自己第一次上线时就在这卡了一晚上后面部署章节会重点提醒。3. 数据库设计四张表把一个学期的考勤安排明白数据库设计是这套系统的地基我一开始没设计好中期返工过一次所以这部分经验特别想分享出来。核心原则是把“课程”和“签到”当成两个独立实体中间用关联表连接。3.1 学生表、教师表用 openid 做微信身份关联在微信小程序场景下用户的唯一标识是 openid。每个用户在小程序里访问后端通过 code 换来的 openid 是稳定的。所以学生表和教师表一定要有一列存 openid然后再加上业务需要的字段。学生表student字段类型说明idint 自增主键openidvarchar(64)微信用户唯一标识student_novarchar(20)学号namevarchar(50)姓名class_namevarchar(50)班级名称create_timedatetime创建时间教师表teacher结构基本一样只是把student_no换成teacher_no。当时我把学生和老师都塞在同一张 users 表里用角色字段区分后来发现考勤查询时经常要 join逻辑绕了很多拆开反而省心。3.2 课程表与选课关联表多对多关系的标准解法一门课有多个学生一个学生选多门课这是典型的多对多关系必须有中间关联表。课程表course要包含课程本身的属性也要包含签到所需的默认位置信息和时间规则字段类型说明idint 自增主键teacher_idint关联教师表course_namevarchar(100)课程名semestervarchar(20)学期如 “2024-2025-1”checkin_latdecimal(10,6)默认签到纬度checkin_lngdecimal(10,6)默认签到经度checkin_radiusint允许的定位误差范围米checkin_starttime默认允许签到开始时间checkin_endtime默认允许签到结束时间选课关联表course_student只需要三个字段id、course_id、student_id。导入学生名单时批量往这张表里插数据就行。这张表也是统计“应到人数”的唯一依据。3.3 签到记录表一次签到的完整证据链签到记录表是整个系统里数据量最大、也最关键的表。我设计的时候特意把“时间和位置”都存下来为的是后端判定是否有效老师也能随时追溯。字段类型说明idint 自增主键course_idint关联课程student_idint关联学生checkin_datedate签到日期checkin_timedatetime具体签到时间statustinyint0 正常签到1 迟到2 补签3 异常latdecimal(10,6)签到时的纬度lngdecimal(10,6)签到时的经度location_textvarchar(200)前端传回来的地址描述dynamic_codevarchar(32)本次签到使用的动态码create_timedatetime记录创建时间这里有一个容易被忽略的点checkin_date一定要单独存不要只依赖checkin_time。因为老师可以把签到窗口跨天设置比如晚上十点发签到截止到第二天早上八点如果只存 datetime统计“今天哪些人签到了”就会出问题。我当时还加了一个course_schedule表用来存课程的每次上课日期比如“1-16周每周二第3-4节”。这样老师发布签到的时候可以直接选择第几周哪节课不用每次手动写时间体验会好很多。4. Flask 后端登录鉴权、课程管理与签到接口的实现细节后端这部分我挑核心接口讲包括小程序登录、创建课程、发布签到、提交签到四个功能。每个接口都贴关键代码说明为什么这么写。4.1 小程序登录code2session 换 openid 与 token 签发小程序端调用wx.login()拿到一个临时 code后端拿着这个 code 去微信的接口换 openid 和 session_key。这一步必须由后端代劳不能在小程序端直接请求微信接口因为需要用到 AppSecret这个东西绝不能暴露在客户端。# app/api/auth.py import requests import time import hashlib from flask import Blueprint, request, jsonify from config import APP_ID, APP_SECRET from models import db, Student, Teacher auth_bp Blueprint(auth, __name__) auth_bp.route(/api/login, methods[POST]) def login(): data request.get_json() code data.get(code) role data.get(role, student) if not code: return jsonify({code: 400, msg: 缺少code参数}) url ( https://api.weixin.qq.com/sns/jscode2session f?appid{APP_ID}secret{APP_SECRET}js_code{code} grant_typeauthorization_code ) resp requests.get(url).json() if errcode in resp: return jsonify({code: 500, msg: 微信登录失败}) openid resp[openid] # 在对应角色表里查用户不存在则视为未绑定 model Student if role student else Teacher user model.query.filter_by(openidopenid).first() if not user: return jsonify({code: 401, msg: 未绑定账号, openid: openid}) # 生成简单 tokenopenid 时间戳 密钥的哈希 token_source f{openid}:{time.time()}:your-secret token hashlib.sha256(token_source.encode()).hexdigest() return jsonify({ code: 0, data: { token: token, user: { id: user.id, name: user.name, role: role } } })token 我这边没有引入 JWT直接用了哈希串对于校内小规模系统已经够用。token 生成后可以存到 Redis 里做有效期控制也可以不存靠时间戳判断看你自己服务器资源情况。如果学生规模几百人用 Redis 更规范一些。4.2 创建课程与发布签到把教师的操作收敛成两个动作老师创建课程时前端表单提交课程名称、学期、默认签到经纬度、半径和时间范围。后端写入course表同时往course_student表插入学生名单——学生名单的来源可以是手动选择班级批量导入。发布签到的逻辑稍微复杂一点。因为同一门课在一天里可能有多节课我用course_schedule表记录每次课的时间和唯一标识。老师选择某次课点击“发起签到”后端生成一条带dynamic_code的签到活动记录并设置有效时间窗口。# app/api/course.py import random import string from datetime import datetime course_bp.route(/api/course/int:course_id/start_checkin, methods[POST]) def start_checkin(course_id): data request.get_json() schedule_id data.get(schedule_id) # 生成一个 6 位动态码作为签到口令 dynamic_code .join(random.choices(string.ascii_uppercase string.digits, k6)) # 默认签到窗口前后各 15 分钟 now datetime.now() window_start now.strftime(%Y-%m-%d %H:%M:%S) window_end (now timedelta(minutes30)).strftime(%Y-%m-%d %H:%M:%S) checkin_session CheckinSession( course_idcourse_id, schedule_idschedule_id, dynamic_codedynamic_code, start_timewindow_start, end_timewindow_end, status1 # 1 进行中 ) db.session.add(checkin_session) db.session.commit() return jsonify({ code: 0, data: { session_id: checkin_session.id, dynamic_code: dynamic_code, start_time: window_start, end_time: window_end } })这里我刻意把签到时间窗口设计成默认 30 分钟。太短了学生来不及操作太长了代签的风险会指数上升。30 分钟这个值是在我们学校试用两周之后定下来的你可以根据自己课程节奏调整。4.3 提交签到接口一次请求里完成所有判定提交签到的接口是核心中的核心所有防代签逻辑都在这里串起来。请求参数包括session_id、学生 token、经纬度、动态码。checkin_bp.route(/api/checkin/submit, methods[POST]) def submit_checkin(): data request.get_json() session_id data.get(session_id) lat data.get(lat) lng data.get(lng) dynamic_code data.get(dynamic_code) student_id data.get(student_id) session CheckinSession.query.get(session_id) if not session or session.status ! 1: return jsonify({code: 400, msg: 签到活动不存在或已结束}) now datetime.now() if not (session.start_time now session.end_time): return jsonify({code: 400, msg: 不在签到时间窗口内}) if dynamic_code ! session.dynamic_code: return jsonify({code: 400, msg: 动态码不正确}) course Course.query.get(session.course_id) distance haversine( float(lng), float(lat), float(course.checkin_lng), float(course.checkin_lat) ) if distance course.checkin_radius: return jsonify({code: 400, msg: f距离签到点过远当前相距{distance:.0f}米}) # 查重同一学生同一节课只能签一次 exists Attendance.query.filter_by( session_idsession_id, student_idstudent_id ).first() if exists: return jsonify({code: 400, msg: 你已经签到过了}) attendance Attendance( session_idsession_id, student_idstudent_id, checkin_timenow, latlat, lnglng, dynamic_codedynamic_code, status0 ) db.session.add(attendance) db.session.commit() return jsonify({code: 0, msg: 签到成功})判定顺序很重要先判断活动是否有效再判断时间、动态码、距离最后查重写入。这样就算客户端被恶意图包也无法绕过任何一个环节。5. 小程序前端学生端与教师端的页面结构和交互流程前端我用的是微信小程序原生框架不用 uniapp 之类的跨端方案因为项目只在微信生态里跑没必要引入一层编译原生框架的 API 兼容性和调试体验反而更好。5.1 学生端课程列表、签到页、出勤记录三个页面小程序首页是课程列表学生登录后通过 token 向/api/my_courses接口请求自己选的所有课程。这里有一个网络热词里提到的“页面列表加载更多”需求——当学生选的课超过十门时列表要支持下拉分页。// pages/courses/courses.js let page 1; const pageSize 10; function loadCourses(reset false) { if (reset) { page 1; this.setData({ courseList: [], hasMore: true }); } wx.request({ url: ${app.globalData.baseUrl}/api/my_courses, data: { page, pageSize }, header: { token: wx.getStorageSync(token) }, success: (res) { const list res.data.data.list; this.setData({ courseList: this.data.courseList.concat(list), hasMore: list.length pageSize }); page 1; } }); } Page({ onLoad() { this.loadCourses(true); }, onReachBottom() { if (this.data.hasMore) this.loadCourses(false); } });点击课程卡片进入课程详情如果当前有正在进行的签到活动页面会显示一个大大的“签到”按钮。签到按钮点击后先调用wx.getLocation获取定位再弹窗让学生输入老师公布的 6 位动态码最后一起提交。定位授权失败要给用户明确的提示并且在按钮上准备好重试机制不然学生一点不到签到就会着急。出勤记录页面就是一个列表按课程分组展示自己每次签到的时间、状态。这个页面数据量一般不大一次拉全量就行。5.2 教师端发布签到、实时名单、统计报表教师端的课程列表加了“管理”入口。点进课程管理页可以看到学期日历选中某一次课后点“发起签到”后端生成签到会话并返回动态码页面用大号字体展示动态码方便老师投屏或直接口头念给学生。课程管理页还有一个实时刷新按钮点击后请求/api/checkin/session/id/list返回当前已签到学生列表按签到时间排序。投屏模式下每隔 30 秒自动刷新一次老师不用自己反复点。老师还要面对一个高频需求下课的时候就能知道谁没来。我在教师端做了一个“缺勤名单”tab已签到人数、应到人数、未签到名单一目了然省去课后人工对名单的麻烦。5.3 顶部导航栏高度与页面适配的坑这里分享一个做小程序页面时特别容易踩的坑自定义导航栏高度。考勤小程序里我用了自定义顶部导航因为想在导航栏放课程名和签到状态切换。结果发现不同手机顶部状态栏高度不一样刘海屏和普通屏差很大。解决办法是使用微信提供的接口动态计算// app.js const systemInfo wx.getSystemInfoSync(); globalData.statusBarHeight systemInfo.statusBarHeight; globalData.navBarHeight systemInfo.platform ios ? 44 : 48;拿到高度后再给页面容器的padding-top赋值不能写死。这个坑我在真机调试时花了不少时间写死 64px 在 iPhone 上会直接挡住返回按钮。6. 防代签三重验证时间窗口、定位围栏与动态签到码防代签是这套系统最核心的价值没有这个功能考勤系统就是一个摆设。这里详细讲一下三层验证的设计逻辑和参数选择。6.1 第一层时间窗口把代签的成本拉高时间窗口是最基础的约束。每次签到活动只开放 30 分钟超过就没有任何办法在正常流程里签到。代签者要替别人签到必须在知道活动开始的情况下拿到对方的账号和定位在 30 分钟内完成操作这个时间成本直接过滤掉大部分“顺手帮个忙”的情况。时间窗口具体多长我建议用两周试用期测一下。我一开始设的 20 分钟结果经常有学生下课后才想起来没签到老师还要手动补签拉到 30 分钟之后正常完成率提高到 95% 以上又不会宽松到让学生提前离开教室还能签上。6.2 第二层定位围栏防“人不在教室”在发布课程时老师需要在教室位置设置一个经纬度圆心和半径。学生提交签到时后端用 Haversine 公式计算学生上报坐标和圆心之间的距离超过半径就拒绝。Haversine 公式的关键代码# utils/geo.py from math import radians, cos, sin, asin, sqrt def haversine(lon1, lat1, lon2, lat2): 计算两个经纬度点之间的球面距离单位米 R 6371000 dlon radians(lon2 - lon1) dlat radians(lat2 - lat1) a (sin(dlat / 2) ** 2 cos(radians(lat1)) * cos(radians(lat2)) * sin(dlon / 2) ** 2) return round(2 * R * asin(sqrt(a)), 1)半径设多少是个经验活。教室大一点的WiFi 信号覆盖范围可能要 50 米教学楼走廊多、GPS 信号反射严重的误差能到 100 米。我最后给老师留了自定义配置默认 50 米遇到定位不准的课程手动调到 100 米。这里有个很实用的技巧后端判定距离时不要用“严格小于半径”这种绝对判断而是允许 20% 的余量并且把实际距离返回到错误信息里。学生看到一个“当前距离 62 米超出范围 12 米”就知道往教室中心走走再试而不是一头雾水。6.3 第三层动态签到码防“截图代签”动态码是三层验证里最有意思的一层。每次签到活动生成一个 6 位随机码老师在课上口头报出来或投屏展示学生签到时要输入这个码才能提交。这个机制的关键在于码会过期且每个码只能用于当前签到会话。代码在发布签到接口里生成用random.choices从大写字母和数字里选 6 位碰撞概率很低足够课内场景用。为了防止学生把动态码截图发到群里长期使用后端会在签到结束时间之后让所有旧码失效同时每节新课必须重新发起签到生成新码。三层验证叠加起来的效果是代签者必须同时知道学生微信账号、在正确的时间窗口内、出现在教室范围内、拿到当次动态码四个条件缺一不可。做到这个程度代签成本已经高到没人愿意干了。7. 部署上线与踩坑实录域名校验、服务器配置和常见问题系统开发完不算完能不能稳定跑完一个学期才是真正的考验。部署阶段我踩了不少坑挑几个最有代表性的说。7.1 小程序域名白名单第一次上线必卡点微信小程序正式版要求所有请求域名必须是 HTTPS且在小程序管理后台“开发管理-服务器域名”里配置好白名单。request 合法域名可以配置 20 个域名不允许带端口路径不限。我踩的坑是当时图省事在小程序开发工具里勾了“不校验合法域名”本地调试能通但真机预览一上就全部请求失败报错信息是bad url。排查半天才发现是忘了在小程序后台添加服务器域名。这个坑几乎每个做小程序的人都会遇到建议上线前先做好这两步在微信公众平台把域名加到 request 合法域名列表。在小程序开发工具里把“不校验合法域名”的勾选去掉用真机跑一遍完整流程。7.2 Flask 部署Gunicorn Nginx 的标准姿势Flask 自带的开发服务器只适合调试直接上生产必然有问题。我的部署方案是 Gunicorn 起多进程跑 FlaskNginx 做反向代理和 HTTPS 终止。# 安装依赖 pip install gunicorn flask flask-sqlalchemy pymysql # 生产启动4 个 worker绑定本机 8000 端口 gunicorn -w 4 -b 127.0.0.1:8000 app:appNginx 配置核心部分server { listen 443 ssl; server_name your.domain.com; ssl_certificate /path/to/cert.pem; ssl_certificate_key /path/to/cert.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; } }这里有个隐藏问题proxy_set_header X-Real-IP $remote_addr不写的话Flask 端拿不到用户真实 IP做登录频率限制时 IP 全是 Nginx 的内网地址等于限制失效。我第一次上线就漏了这几行后来补上才好。数据库我建议开发时用 SQLite 省事但上线后换 MySQL。原因很简单SQLite 并发写入能力有限考勤签到高峰是同一时间几十个请求同时写SQLite 会频繁报database is locked。用 MySQL SQLAlchemy只要把连接串改一下代码几乎不用动。7.3 生产环境常见问题排查思路小程序真机请求失败先看 console 里的报错基本是域名白名单、HTTPS 证书过期、或服务器防火墙没放开 443 端口按这个顺序查。签到定位偏差大让学生关掉 WiFi 只用 GPS或者加大半径。数据统计对不上优先检查checkin_date是不是 UTC 时间Python 服务器时区没设置好的话写入的时间可能和北京时间差 8 小时所有日终统计都会错。课程列表加载慢了确认是否在course_student表的 course_id 上建了索引这种小数据量项目慢多半是索引缺失。部署完成后我还做了一件事每周跑一次脚本把异常签到状态为 3 的和补签记录汇总成邮件发给老师提醒老师及时核实。这样到了期末统计出勤率的时候数据基本是干净的。我个人在实际开发里的最大体会是考勤系统真正的难点不在“做出来”而在“让老师愿意天天用”。功能再多如果签到流程要三步以上、老师看一眼缺勤名单都要等五秒这个系统就会被抛弃。所以我在设计每个页面时都反复问自己这个操作能不能一步完成数据能不能一眼看懂所有判断都围绕少操作、快反馈、可追溯来做学期结束之后回访老师们的使用率保持在九成以上这套系统才算真正立住了。