带过班或者上过课的人都知道,每节课点名这事儿看起来简单,真做起来全是心塞。四五十人的班级,一一点名要花三五分钟;喊“到”的时候还有可能替人应声;到了期末统计出勤率,翻着纸质点名册一个个数,算错两三个太正常了。我自己也经历过这个阶段,所以去年做了一套基于Python Flask后端 + 微信小程序的班级课程考勤签到系统,把开课、签到、统计整个链路理顺了。这篇文章就把这套系统的设计和落地过程完整讲一遍,适合正在考虑做考勤类小程序、或者想用Flask快速支撑一个微信小程序业务后端的同学参考。
1. 考勤这个场景,到底难在哪:先把需求拆干净
很多人在做考勤系统之前,第一反应是“不就是个打卡嘛,做个页面记录一下时间就行”。真动手之后才会发现,考勤系统最麻烦的部分根本不在打卡本身,而在“确认这个人确实来了”和“数据能方便统计”这两件事上。
1.1 用户角色与核心流程:老师、学生、管理员三视角
一套班级课程考勤系统,表面上只有“学生签到”一个动作,实际上牵扯三类角色。
- 教师端:需要创建课程、发布一次签到、实时看到已签到学生名单、查看某门课的整体出勤率。
- 学生端:看到自己选了的课程列表、进入课程后点击签到、查看自己的出勤记录。
- 系统管理员(很多时候就是老师自己兼任):维护学生名单、导入选课关系、处理异常签到数据。
核心流程说起来也简单:老师创建一个课程(比如“Python程序设计”,每周二上午三四节),然后把学生批量导入或由学生自助绑定;上课时老师在小程序里点“发起签到”,学生端立即看到可签到状态,点击签到并授权定位,后端记录时间、位置、状态;课后老师可以在后台或小程序里按课程、按日期、按学生维度导出统计。
1.2 真正让考勤变复杂的三件事
第一是防代签。一个学生可以把自己的微信给同学,或者把自己的小程序二维码截图发出去让别人帮忙扫。纯记录时间的方式完全防不住这种操作,必须组合时间窗口、定位、动态码等手段。
第二是数据维度的组织。一个学生一学期选多门课,一门课有多节课,每节课有多次签到活动。如果没有把“课程、课时、学生、签到”拆成独立的数据结构,后期统计一定会混乱到崩溃。
第三是异常处理。学生手机定位不准、老师晚发布了签到、学生忘记签到了需要补签,这些真实场景里几乎每周都会发生。考勤系统必须给老师提供“手动补签”和“撤销签到”的能力,否则系统只会给老师添堵。
我见过不少考勤项目最后做不下去,不是因为代码写不出来,而是因为把需求想得太简单,做出来一个只能“点按钮+记时间”的玩具,老师用两周就放弃了。所以这块我把需求拆解放在第一位,后面所有设计都围绕这三件事展开。
2. 技术选型:Flask + 微信小程序这套组合的取舍逻辑
技术选型这事,没有绝对的对错,只有合不合适。我当时评估过好几套方案,最后落地的是 Flask + 微信小程序原生框架 + MySQL,这套组合在校园场景里非常务实。
2.1 为什么后端用 Flask 而不是 Django 或 Node.js
Django 功能完整,自带 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:
| 字段 | 类型 | 说明 |
|---|---|---|
| id | int 自增 | 主键 |
| openid | varchar(64) | 微信用户唯一标识 |
| student_no | varchar(20) | 学号 |
| name | varchar(50) | 姓名 |
| class_name | varchar(50) | 班级名称 |
| create_time | datetime | 创建时间 |
教师表teacher结构基本一样,只是把student_no换成teacher_no。当时我把学生和老师都塞在同一张 users 表里,用角色字段区分,后来发现考勤查询时经常要 join,逻辑绕了很多,拆开反而省心。
3.2 课程表与选课关联表:多对多关系的标准解法
一门课有多个学生,一个学生选多门课,这是典型的多对多关系,必须有中间关联表。
课程表course要包含课程本身的属性,也要包含签到所需的默认位置信息和时间规则:
| 字段 | 类型 | 说明 |
|---|---|---|
| id | int 自增 | 主键 |
| teacher_id | int | 关联教师表 |
| course_name | varchar(100) | 课程名 |
| semester | varchar(20) | 学期,如 “2024-2025-1” |
| checkin_lat | decimal(10,6) | 默认签到纬度 |
| checkin_lng | decimal(10,6) | 默认签到经度 |
| checkin_radius | int | 允许的定位误差范围(米) |
| checkin_start | time | 默认允许签到开始时间 |
| checkin_end | time | 默认允许签到结束时间 |
选课关联表course_student只需要三个字段:id、course_id、student_id。导入学生名单时,批量往这张表里插数据就行。这张表也是统计“应到人数”的唯一依据。
3.3 签到记录表:一次签到的完整证据链
签到记录表是整个系统里数据量最大、也最关键的表。我设计的时候特意把“时间和位置”都存下来,为的是后端判定是否有效,老师也能随时追溯。
| 字段 | 类型 | 说明 |
|---|---|---|
| id | int 自增 | 主键 |
| course_id | int | 关联课程 |
| student_id | int | 关联学生 |
| checkin_date | date | 签到日期 |
| checkin_time | datetime | 具体签到时间 |
| status | tinyint | 0 正常签到,1 迟到,2 补签,3 异常 |
| lat | decimal(10,6) | 签到时的纬度 |
| lng | decimal(10,6) | 签到时的经度 |
| location_text | varchar(200) | 前端传回来的地址描述 |
| dynamic_code | varchar(32) | 本次签到使用的动态码 |
| create_time | datetime | 记录创建时间 |
这里有一个容易被忽略的点: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_type=authorization_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(openid=openid).first() if not user: return jsonify({'code': 401, 'msg': '未绑定账号', 'openid': openid}) # 生成简单 token:openid + 时间戳 + 密钥的哈希 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, k=6)) # 默认签到窗口:前后各 15 分钟 now = datetime.now() window_start = now.strftime('%Y-%m-%d %H:%M:%S') window_end = (now + timedelta(minutes=30)).strftime('%Y-%m-%d %H:%M:%S') checkin_session = CheckinSession( course_id=course_id, schedule_id=schedule_id, dynamic_code=dynamic_code, start_time=window_start, end_time=window_end, status=1 # 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_id=session_id, student_id=student_id ).first() if exists: return jsonify({'code': 400, 'msg': '你已经签到过了'}) attendance = Attendance( session_id=session_id, student_id=student_id, checkin_time=now, lat=lat, lng=lng, dynamic_code=dynamic_code, status=0 ) 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 起多进程跑 Flask,Nginx 做反向代理和 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 的)和补签记录汇总成邮件发给老师,提醒老师及时核实。这样到了期末统计出勤率的时候,数据基本是干净的。
我个人在实际开发里的最大体会是:考勤系统真正的难点不在“做出来”,而在“让老师愿意天天用”。功能再多,如果签到流程要三步以上、老师看一眼缺勤名单都要等五秒,这个系统就会被抛弃。所以我在设计每个页面时都反复问自己:这个操作能不能一步完成?数据能不能一眼看懂?所有判断都围绕少操作、快反馈、可追溯来做,学期结束之后回访,老师们的使用率保持在九成以上,这套系统才算真正立住了。