课程签到系统从0到1:基于uniapp与Python的课堂考勤开发实践
1. 为什么要做一个独立的课程签到系统课堂考勤的痛点拆解1.1 从传统点名方式到信息化考勤的迁移我带过不少课程也帮几所院校做过信息化改造发现课堂签到这件事看起来小实际很考验系统设计。传统点名方式无非是纸质名单一个个喊或者班长手写记录再或者用微信群接龙。这些问题摆在明面上纸质名单容易丢失课后统计费时间代喊、代签根本无法识别更别说把出勤率、迟到次数、请假记录这些数据汇总成一份可看的报表。到了期末计算平时分时面对一堆零散的纸张和聊天记录整个人都是崩溃的。最初我试着用现成的打卡软件应付但很快发现问题。市面上通用考勤软件面向企业办公场景字段里全是部门和排班和高校的课程维度对不上。老师要的是“这门课今天有多少人到了、谁迟到、谁请假、哪些学生连续缺勤”学生要的是“进入课堂后一键完成签到不用下载一堆App”。这些需求用企业考勤软件去满足体验非常割裂。所以当决定做一套面向教师的课程签到管理系统时我把目标定得很清楚不做一个炫技的大平台而是精准覆盖教师端“创建课程、发起签到、查看统计、管理学生”这条主线同时给学生提供一个足够轻的参与入口。于是技术方案选定为 uniapp 开发双端应用配合 Python 编写后端接口安卓原生打包给教师使用学生端通过小程序完成扫码和签到。整套系统跑起来之后课堂考勤这件事终于不再靠人肉和口口相传了。1.2 一套系统要覆盖哪些真实角色和流程动手之前把角色和流程梳理清楚项目一般不会跑偏。这个系统里实际站着三类角色教师、学生、教务管理人员。教师是核心用户日常操作包括登录、维护个人课程、给课程发起一次签到、查看某次签到的完成情况、处理学生的请假申请、导出某门课的出勤率报表。学生不直接使用管理端的复杂功能他们的核心动作只有两个第一是进入某个课堂时打开小程序扫码第二是查看自己本学期有哪些课程的签到记录。还有一个容易被忽略的角色是教务管理员他们不一定天天登录系统但在期末需要从全局查看各个班级、各门课程的出勤数据。因此在设计角色权限时没有把系统做成单角色应用而是采用了“教师—学生—管理员”三套菜单。教师端默认是安卓 App学生端是微信小程序管理员端直接用浏览器访问后台同一套 Python 接口服务通过不同登录入口和 Token 中的角色标识控制权限。这样一来功能边界很清楚签到的发起权在教师手里参与权在学生手里统计汇总权向管理员开放。基于这些真实使用场景我在功能规划上确定了几个硬性模块课程管理课程 CRUD 与班级导入、签到任务发起、关闭、查看结果、学生管理绑定学号、批量导入、请假审批学生申请、教师处理、数据报表按课程、按时间范围统计出勤率。前前后后大概花了两周就沉淀出了第一版可用的系统。2. 技术选型背后的判断为什么是 uniapp Python而不是其他组合2.1 uniapp 多端复用的现实收益技术选型没有统一答案完全取决于项目定位。这一套系统的需求从一开始就出现了两个端教师端要长期高频使用需要可靠的本地推送、桌面图标和原生交互所以优先考虑安卓 App学生端要尽量降低使用门槛最好扫码即用不用安装额外软件微信小程序是最合适的载体。如果分别用 Java 写一个安卓原生再用原生小程序语法写一个微信小程序等于同一套界面逻辑要实现两遍对于一个校级工具型项目来说成本太高。uniapp 的核心价值在于它基于 Vue 语法一套代码可以分别编译到安卓 App、iOS App、微信小程序及各平台小程序。我在这个项目里实际体验下来90% 的公共页面和业务逻辑完全不区分平台只有涉及原生能力时需要通过条件编译进行差异化处理。比如教师端需要保存本地会话在 App 里可以使用uni.setStorageSync小程序端存在某些限制但大部分存储 API 是统一的代码几乎可以一行不改。选 uniapp 还有一个很现实的理由后端是 Python 提供的 JSON 接口前端不需要关心平台差异更关心的是请求封装、页面状态管理和数据渲染。uniapp 的生命周期基本沿用了 Vue 的模型同时针对页面提供了onLoad、onShow、onPullDownRefresh等页面钩子开发者可以把注意力集中在业务逻辑上。我在项目里用 Vite Vue 3 组合方式初始化 uniapp 项目编译到安卓和微信开发者工具里都表现正常。对于一个长期维护的开源学校项目这种技术栈显然比双端原生代码更可控。2.2 Python 后端小团队快速迭代的最优解后端选择 Python 不是因为它性能最好而是因为在这个规模下它是迭代效率最高的选择。我最终使用了 FastAPI 作为 Web 框架。相比 DjangoFastAPI 更轻量用装饰器声明路由即可相比 FlaskFastAPI 自带请求参数校验和自动生成 OpenAPI 文档这对于要对多个端开放接口的项目的调试和排错是巨大的加分项。Python 生态里处理签到类业务很便捷。比如后端需要用随机码生成签到二维码内容标准库secrets就能搞定安全随机字符串记录签到时间时用datetime配合zoneinfo可以规避时区差统计出勤率时直接用sqlalchemy的聚合查询就能取出结果。这些操作写起来非常短但实际踩坑不少后面我会专门讲到。我采取的方案是 FastAPI SQLAlchemy 2.0 SQLite同时在配置里预留 MySQL 连接串方便生产环境切换。对于单校几千名学生的访问量SQLite 甚至够用但考虑到未来可能多个院系并行使用我直接抽象了数据库会话层切库时只需修改环境变量。前后端分离的结构下后端只暴露 RESTful API不关心哪个客户端在调用教师用的安卓端、学生用的小程序以及管理员浏览器访问的界面全都走同一组接口服务端只需要做统一的 Token 鉴权即可。2.3 前后端交互协议与工程目录规划前后端采用的是最常规的 JSON over HTTP签到时二维码内容有限不适合传大量参数因此二维码里只放“签到任务ID 随机token 过期时间戳”三个字段。学生扫码后小程序解析出这些信息和当前登录学生信息组合发给签到接口后端再去校验签到任务的合法性。这个设计的前提是接口结构必须稳定我提前把所有接口按模块划分并整理成了接口文档。推荐一下我习惯的 uniapp 工程目录划分pages下按teacher、student两个模块存放页面api目录集中管理所有请求方法utils放请求封装、鉴权处理和格式化函数static放图片与静态配置。请求方法封装时需要注意 uni.request 在 App 和小程序端的返回结构略有差异我会在拦截器里统一清理响应结构这样业务页面拿到的始终是{ code, data, msg }三段式内容。后端目录结构也按模块划分routers里放课程、签到、用户、统计等路由文件models里放数据库模型schemas里放 Pydantic 校验模型services里放核心业务逻辑。这套结构最直接的好处是哪天想在签到业务里增加人脸比对或位置校验只需要新增一个 service 文件并在路由里调用不会把逻辑散落在各处。3. 后端服务与数据库设计先把签到的“业务底盘”打稳3.1 核心模型设计用户、课程、签到任务、签到记录签到系统的复杂度不高但数据表之间的关系必须一开始就定好否则后面做统计时会反复调整接口。我的数据库里核心表一共五张用户表、学生档案表、课程表、课程选课关联表、签到任务表和签到记录表。用户表存的是登录凭据字段包括user_id、username、password_hash、role、nickname。密码一律用passlib里的 bcrypt 做哈希存储不存明文。学生档案表扩展了学号、班级、专业等字段使用user_id与用户表形成一对一关联。课程表的字段更贴近教学场景除了课程名称、课程代码、上课周数外还存了教师user_id即该门课程的负责人。签到任务表记录了教师每一次发起的签到course_id关联哪门课task_token存签到二维码里的随机校验码expire_time存签到截止时间remark允许教师补充说明。签到记录表则是最核心的行为数据一个学生的一次签到会形成一条记录字段包括task_id、student_id、sign_time、latitude、longitude、status。这里的status我设计成多取值正常、迟到、请假、缺勤方便教师事后手动调整。课程选课关联表是统计报表的关键。实际开发中特别容易犯的错是某门课有 80 个学生选课但只来了 75 个如果只统计“谁签到了”根本不知道有 5 个人没来。所以必须先把“这门课属于哪些学生”固化成关系表签到时登记的记录和这张表做左连接才能真正算出缺勤名单。具体创建表的 SQL 逻辑写成 SQLAlchemy 的Mapped映射后基本可以实现自动建表不需要手写迁移非常方便。3.2 签到核心逻辑二维码 / 时间段 / 状态机三重校验签到功能表面上是“学生扫一下码记录成功”但实际上后端必须同时完成三重校验缺一不可。第一重校验是二维码是否有效。二维码内容里包含任务 ID 和 token后端在签到接口中先去查签到任务表校验 token 是否匹配且任务未被关闭。这个步骤可以拦截已经过期作废的二维码。第二重是时间段校验。教师发起签到时通常要设定一个截止时间比如“上课后 10 分钟内有效”。学生在截止时间内提交才算正常签到超过截止时间后提交应该记为迟到并向学生给出提示。这里的时间判断不要用前端传过来的时间一定以后端服务器当前时间datetime.now(timezone.utc)为准。前端设备的时间可以随意修改如果信用系统依赖前端时间代签和作弊会变得非常简单。第三重是防重复签到。后端需要根据task_id student_id的唯一约束做一次查询如果该学生已经存在有效签到记录直接返回“你今天已经签到过了”。如果教师允许学生在签到截止前重新更新签到状态也必须走一个显式的更新接口而不是无脑插入新记录。这里建议在数据库层面为签到记录表加上联合唯一索引防止并发请求下两条记录同时写入。实现过程中比较巧妙的地方是签到状态机。我定义了一个SignStatus枚举常量初始状态是pending学生扫码提交后由后端根据时间自动判定为normal或late教师在小程序端手动点击“补签”则状态变为manual。请假审批通过后对应签到记录会直接写入leave状态。这样无论数据来源是什么最终记录表里都只有一条稳定的状态数据统计报表时直接基于状态列做分组非常干净。3.3 接口鉴权与通行策略接口上的核心设计是登录刷新策略与端侧识别。我在 Python 后端用了 JWT 作为登录凭证签发时把user_id、role放进 payload 里。前端每次请求都在Authorization头携带BearerToken后端写一个依赖函数统一校验。对于需要教师权限的接口还可以再加一层角色校验比如发起签到和关闭签到这两个接口。学生扫码签到的接口要求 Token 的身份是学生否则一律返回 403。一个值得注意的问题是 JWT 过期时间怎么设置。教师使用安卓端频率高如果把 Token 有效期设置为 7 天那么教师基本不会有频繁登录的体验问题。但学生端使用小程序是低频的可能一周只用两次。两种角色使用频度差异较大因此我在登录接口后端根据用户角色动态设置不同的过期时间教师端 7 天学生端 3 天管理员端 1 天。有效期内如果 Token 快过期由后端返回一个特定状态码提示前端使用存储的 refresh_token 静默刷新前端封装拦截器统一处理即可。在单次签到场景中二维码 payload 本身不包含 Token因为二维码可能被转发传播。它的有效期被设置为 10 分钟配合随机字符串避免被枚举。也就是说即使一个学生把二维码截图发到群里他的同学也可以扫码但这个二维码只对应一个签到任务所以跨课程作弊没有意义。如果是同一门课的多人代签则还需要结合位置校验或点名抽查这部分我在后面的防作弊调优里详细展开。4. uniapp 端从 0 到 1 的实现拆解教师端界面与学生端小程序4.1 教师端安卓 App任务流程与页面实现教师端的核心任务是创建课程并管理签到过程。我把主页面划分为四个页签工作台、课程、统计、我的。工作台显示今天有课的日程卡片和最近一次签到的快捷入口。课程页展示教师创建的课程列表点击进入课程详情后能发起签到、查看历史和编辑课程信息。“发起签到”页有几个关键参数允许签到时长、是否需要位置校验、签到说明。我原以为时长选择是最简单的下拉框但实际使用中教师经常需要快速发起签到。因此我把“5分钟、10分钟、15分钟、自定义”这四种时长做成按钮平铺在页面顶部教师进入页面后一键选中即可不需要展开多级菜单。课程详情页里签到列表需要实时展示“已签到人数/应到人数”。如果使用 setInterval 每 5 秒请求一次接口会在弱网环境下造成大量请求失败。我的做法是发起签到成功时返回一个task_id页面据此开启轮询当页面调用onHide或onUnload生命周期时清除定时器避免不必要的后台请求。另外我还给签到列表的下方加了“查看签到二维码”的按钮。在安卓 App 端展示二维码使用 uQRCode 插件生成把它渲染到 canvas 上再转成图片方便教师通过手机投屏展示给学生。4.2 学生端小程序签到页面与多端条件编译学生端唯一要完成的功能是签到和查看记录因此页面比教师端精简得多。学生登录进入首页后我默认放置一个“扫一扫签到”按钮。点击后直接调用小程序的wx.scanCodeAPI识别二维码中的内容。这里需要特别注意的是在小程序里获取扫码结果后要隐藏原生的“扫码结果页面”跳转直接进入自定义的签到确认页而不是让系统自动打开链接。同一套源码中同时运行教师端和学生端最大的挑战是入口区分。我使用了 uniapp 的条件编译特性处理平台差异但这里的复杂性不只平台还有角色。因此我在根目录下定义了App.vue的启动逻辑首次进入时读取本地存储的角色信息如果是教师直接重定向到教师工作台如果是学生进入学生端首页如果完全没有登录信息则跳转登录页。登录页中根据用户输入账号的角色调用对应的首页路由这个方案在后续版本里持续运作得很稳定。学生签到页拿到二维码参数后会向服务端发送签到请求。这里需要增强体验如果接口返回迟到状态页面要明确提示“当前时间已超过签到截止时间本次签到将被记录为迟到”学生不会感到突兀如果学生已经签到过接口返回重复状态页面显示“你今天已完成签到无需重复操作”如果学生并未选择这门课却扫了码页面要提示“你不是该课程选课名单内的学生”。这些分支判断直接决定学生和教师能不能感受到系统的“智能”而不是遇到任何错误都弹出统一报错框。4.3 会话保持、签到状态刷新与异常场景处理前端状态管理非常影响真机体验。我在项目里没有引入 Pinia而是用一个简单的store.js模块配合uni.setStorageSync做全局状态缓存。登录成功后存储用户信息、角色和 Token之后每次请求需要重新获取用户资料时通过集中刷新函数清掉缓存并重新拉取接口防止用户修改信息后页面还显示旧数据。对于学生端签到页面有一个隐性需求是“签到状态的实时刷新”。如果一个学生提前到了教室扫码但教师还没有发起签到系统应当提醒“当前课程还没有可参加的签到”。很多团队会把这种情况和后端报错混在一起返回类似“签到任务不存在”的提示学生会误以为系统坏了。实际上后端应该返回一种特殊状态no_active_task前端在收到该状态时自动设置一个 5 秒的定时器反复查询是否有新发起的签到任务。当教师端开始签到时学生端无需任何手动操作就可以自动从“等待签到”切换为“可签到”界面。这一块的异常处理代码比较考验耐心但也决定了系统能不能在真实课堂上被流畅使用。教室网络不稳定时小程序可能请求超时扫码后如果短时间内退出页面请求结果返回时需要判断当前页面是否还存活。这些状况都要在request封装层做统一兜底保留“重试”按钮并给出面向不同角色的友好文案。我在项目初期跳过了一些异常分支后来在试用中频繁收到学生反馈说签到按钮点了没反应排查后发现就是响应里没有做page销毁检测造成的。5. 安卓打包与微信小程序上架两个端各自的“最后一公里”5.1 安卓 APK 打包流程与签名配置uniapp 开发完安卓端后真正进入打包阶段会遇到不少工程化细节。我使用 Android 离线打包和云打包结合的方式进行尝试。云打包流程更简单直接在 HBuilderX 中配置 DCloud 开发者账号填写应用包名、图标、启动页和证书指纹然后提交云端打包生成 APK。离线打包则适合需要深度集成原生模块的场景比如当需要自定义推送通道或集成特定厂商 SDK 时我会先借助 Android Studio 生成基座包再进行二次开发。签名文件是安卓打包过程中最容易踩坑的一环。云打包时如果未配置正式签名应用安装后无法覆盖安装开发版本且一旦应用上架市场需要更新时无法使用同一个签名包做增量升级。我通过 Android Studio 的Generate Signed Bundle / APK生成了一个正式的.keystore签名文件并牢记两个密码。需要提醒的是签名文件一定要纳入版本管理仓库之外的安全介质存档。如果签名文件丢失市面上没有任何办法可以让已经发布的应用继续基于原有包名更新只能换包名重新上架。打包完成后还有一个容易忽略的环节安卓的网络权限。在 uniapp 项目的AndroidManifest.xml或云端打包的配置文件里需要声明INTERNET权限否则安卓应用安装后无法访问任何 HTTP 接口。使用 http 明文协议调试时还需要在清单中开启usesCleartextTraffictrue否则安卓 9 以上设备会拒绝明文流量。这个配置问题经常让人误以为代码写错了折腾一整天最后发现是清单文件没有加权限。5.2 微信小程序绑定域名与隐私协议弹窗处理学生的微信小程序版本发布是完全不同的流程。微信小程序的请求域名必须是 HTTPS 且在公众平台完成 ICP 备案并加入白名单不校验 IP 或端口。因此我在后端配置了 Nginx 反向代理把/api路径转发到本地 FastAPI 服务并在 Nginx 层配置了有效的 SSL 证书。这说起来简单但早期开发阶段如果还没有域名和证书可以先在微信开发者工具里勾选“不校验合法域名”方便本地联调。等到上线前再换成正式的合法请求域名。微信小程序在 2023 年后对隐私协议的要求明显提高。教师用安卓端和学生用小程序端在首次启动时都必须弹出隐私协议和用户协议确认窗口。我的做法是做一个专用的privacy-popup组件在App.vue的onLaunch中判断用户是否已经同意过隐私协议如果同意过就直接进入主流程如果没同意则弹出半透明遮罩层用户必须点击“同意”按钮后才会请求任何接口。如果用户点击“不同意”安卓端可直接调用plus.runtime.quit()退出应用小程序端只能静默停留在协议页面无法登录使用。很多开发者在处理这个需求时忽略了“用户未同意授权前不能调用任何隐私接口”。这里的“隐私接口”包括获取用户手机号、获取位置、读取剪切板等。在签到系统中常见的误区是学生一进登录页就请求位置权限结果被微信拦截导致整个页面白屏。正确做法是先把登录请求发出去在教师发起带位置校验的签到时再动态请求位置权限同时在页面上清晰说明“本次签到需要获取位置”。5.3 版本更新的思路与热更新方案教师端是原生安装的安卓 App后续版本迭代需要一套能落地的更新机制。最简单的做法是教师端在登录后拉取一个版本检查接口后端返回最新版本号、下载地址和更新说明。如果当前版本落后就弹出更新提示框点击跳转到浏览器下载新的 APK。我所在环境没有自己的应用市场渠道因此采用的是这种方式。uniapp 项目还支持资源热更新。通过plus.runtime.update可以只更新应用内的wgt资源包不必重新打包整个 APK。如果项目没有增加新的原生插件或权限配置资源包更新体验接近小程序般流畅。但要注意一旦用到新的原生插件资源包更新就无效了必须重新打包安装。因此我建议把更新机制同时做好两层判断后端返回的更新信息里明确标识本次是“整包更新”还是“资源更新”客户端根据这个标识决定是弹出 APK 下载提示还是静默下载资源包。小程序端不存在安装更新问题微信平台审核通过后用户访问到的代码会自动是最新版。但审核周期和上线节奏必须纳入项目管理中每一次发布涉及服务端接口调整时要保证新版本小程序在审核期间访问旧接口不至于直接报错。我在接口设计中保持了较强的向后兼容比如增加签到字段时不删除旧字段对前端没有传新字段的请求仍按默认值处理从而避免“小程序上架前服务端接口已经改坏旧版还在使用”的尴尬。6. 真实跑课过程中踩过的坑和调优记录6.1 时间与时区问题签到截止时间差 8 小时的教训这个坑几乎每个使用 Python 时间处理的人都会遇到。系统开发初期前端把new Date().getTime()作为签到截止时间传给后端。本地单测时教师端和后台在同一台电脑上表现得完全正常。但是真机一同步问题立刻出现明明学生提前扫码后端却判定已超过截止时间所有签到全部变成迟到。排查发现原因并不复杂。前端获得的时间戳是当前设备本地时间的毫秒值而服务器使用datetime.now(timezone.utc)获取的是 UTC 时间两者之间相差 8 个时区。更麻烦的是前端传过来的时间戳没有带时区标识后端直接把一个已经包含本地时区偏移的时间戳当成 UTC 时间解析自然会出现 8 小时偏差。从那以后我立了一条硬规矩前端不传时间所有时间由后端统一判定。前端只传签到任务 ID截止时间的计算完全在后端完成例如教师设置“10 分钟内有效”后端基于当前 UTC 时间加 600 秒。如果确实需要前端显示截止时间我返回的是 ISO8601 格式且带时区的字符串前端只负责展示不参与规则判断。这样即使某个学生把手机时间改到几天前也无法超过服务器的时间边界。6.2 二维码被代签的应对方案系统刚试用的时候有教师反馈学生通过微信群传播二维码人不在教室也能扫码完成签到。这是任何二维码签到方案都会面临的现实问题。要应对代签几种手段可以组合使用。最简单的手段是缩短二维码有效期。二维码只保留 1 到 2 分钟有效期超过时间后必须由教师重新刷新生成。这种方法根治不了有组织地人在教室外等码的作弊但能明显降低随意传播的可能。更进一步的做法是在设置签到参数时开启地理位置校验。后端在签到记录表中同时存储latitude和longitude教师设定一个允许范围比如教室经纬度半径 300 米内有效。学生扫码时前端通过uni.getLocation获取当前定位一并传给后端后端基于球面距离计算是否允许签到。地理位置方案仍能被模拟定位绕过但会大幅提高作弊成本。我在跑课中还发现低头族学生更希望一次扫码、全程安心所以在教师端增加了“随机点名”的补充功能。教师在课程详情页点击“随机抽查”时后端从已签到名单中随机抽取一名学生前端展示学生照片课堂大屏可以显示抽查结果。从威慑效果看随机抽查和扫码限制的配合比单独使用任何一种都更有效。6.3 数据库连接与接口性能调优开发初期我只顾着写业务逻辑数据库连接是每次请求时新建请求结束再关闭。实际使用时并发只有几十个人接口却频繁出现超时。打开后端日志后发现大量 SQLite 数据库锁错误多个签到请求同时并发写入导致database is locked。SQLite 本身锁定粒度较粗读写并发冲突明显。解决的方案有两个方向一是使用连接池替代频繁开关连接二是写操作通过队列串行化。我最终采用的方法是换用aiosqlite和异步会话同时在 FastAPI 启动时创建 SQLAlchemy 引擎并设置连接池大小。因为使用 SQLite连接池效果有限所以我在写接口上加入了重试装饰器捕获锁错误后等待 50 毫秒再提交一次。后续用户数增长以后又把数据库切到 MySQL整个切换只改了配置字符串和少量方言相关代码。接口层的另一个优化重点是统计数据接口。最初统计教师某门课出勤率时前端一次请求后端循环遍历每个学生的签到记录逐条判断速度奇慢。后来我改用 SQLAlchemy 的func.count和group_by做聚合查询一次查询返回“按签到状态分组的数量字典”接口耗时从秒级降到毫秒级。设计报表接口时要尽量把统计压力留在数据库内避免在 Python 层用 for 循环拼数据尤其是当数据量涨到几千条后循环解法会明显拖垮整个接口的响应速度。最后提一个容易被忽略但非常影响系统形象的点接口返回结构必须始终如一的稳定——成功与失败都要带明确的状态码和提示语。我在第一批试用学生反馈里看到最多的问题是“签到失败请重试”可学生完全不知道失败原因。后来在接口统一报错结构中增加了error_code比如1001代表课程 ID 不存在、1002代表重复签到、1003代表不在选课名单里。安卓端和小程序端的前端拦截器拿到这些错误码后不再直接展示原始接口信息而是按错误码匹配对应的友好文案。经过这次调整学生签到失败后的求助消息少了一大半教师也不再需要远程帮学生猜问题出在哪里了。