十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

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

基于Android的课间签到管理系统设计与防代签方案解析

基于Android的课间签到管理系统设计与防代签方案解析 课间签到这件事很多人以为难在“做一个APP”但真正难的是另一件事老师发出签到后你如何证明点击屏幕的那个人确实坐在教室里、确实是本人。传统的点名喊到浪费课堂时间纸质签到表可以找人代签普通定位签到可以被模拟位置软件伪造固定二维码可以被截图转发到班级群。一套课间签到系统如果只实现了“点击按钮 记录数据库”那它和一张电子点名册没有本质区别。真正有价值的技术工作是把“谁、在什么时间、在什么地点、用什么设备、以什么方式完成签到”这几个维度全部纳入校验体系。这篇文章以“基于 Android 的课间签到管理系统”为例从系统架构、技术选型、Android 客户端实现、服务端接口设计到防代签方案完整过一遍设计与开发流程。如果你正在做 Android 课程设计、毕业设计或者要负责真实场景中的教学签到需求下面这套方案可以直接复用代码也做了最小可运行处理。1. 课间签到真正难的是“如何证明是本人”课间签到和普通签到有一个本质区别时间窗口极短人数基数大且存在有组织的“互助”行为。老师不可能在课间10分钟内逐个核验学生身份也不可能在开始上课后再花5分钟统计出勤。系统必须在极短时间内完成“身份验证—规则校验—结果写入—异常标记”全流程。正因如此课间签到系统的核心设计目标不是“功能全面”而是“校验严格但体验极轻”。现有的签到方案各有各的问题方案优点核心缺陷点名喊到最真实浪费时间无法记录精确时间纸质签到表零成本代签严重统计靠人工固定二维码操作简单截图转发就能代签GPS 定位签到可以有位置依据模拟定位工具可绕过室内定位不准蓝牙 Beacon 签到可以识别距离教学楼需部署设备成本高动态二维码 位置 时间窗口防代签能力较强需要服务端配合开发量稍大动态二维码方案是目前性价比最高的一条路线老师端定时刷新二维码学生端扫码后提交“二维码内容 位置 设备信息 账号身份”服务端统一校验二维码是否过期、是否属于当前课程、位置偏差是否在合理范围内、该账号是否有异常签到行为。即使二维码被转发也会在几十秒内失效而且位置校验会拦住大部分非本教室账号。所以这篇文章的技术主线非常明确用 Android 客户端做轻量交互入口用服务端做全部可信校验用数据库记录可追溯的签到事实。2. 系统整体架构与核心技术选型2.1 整体架构这个系统分三部分Android 客户端、服务端、数据库。Android 客户端承担的角色是“采集与展示”采集扫码结果、定位信息、设备标识展示登录状态和签到记录。服务端承担所有业务规则登录认证、二维码签发与校验、签到的业务合法性判断、记录落库。数据库只负责存储用户、课程、签到记录等基础数据。整个调用链路是这样的老师登录后在课程管理页面点击“发起签到”服务端生成一个带时间戳和随机数的动态二维码。二维码以 Base64 或图片链接的形式返回 Android 端老师端展示给学生。学生打开学生端 APP扫描老师屏幕上的二维码客户端将二维码内容、当前经纬度、设备信息、账号 token 一起提交给服务端。服务端依次校验token 是否有效、二维码是否在有效期内、二维码对应的课程和时间是否正确、学生账号选课关系是否存在、位置偏差是否超限。校验通过写入一条签到记录返回签到成功校验失败返回明确错误码和提示。在这个结构里二维码只是一个“临时令牌”服务端才是真正判断“能不能签到”的地方。客户端做得再花哨如果服务端校验逻辑薄弱系统依然会被轻易绕过。2.2 技术选型模块技术方案选型理由Android 客户端Kotlin CameraX ZXing Retrofit RecyclerView原生生态成熟CameraX 处理扫码预览简单ZXing 负责解码服务端Spring Boot MyBatis-Plus开发效率高适合中小型教学项目数据库MySQL 5.7通用性强方便课设/毕设演示二维码解析服务端生成 Base64 图片客户端用 ZXing 扫描/解析避免依赖第三方云服务身份令牌JWT无状态便于客户端携带和验证如果只是做演示服务端不引入 Spring Boot改用 Node.js、Python Flask、甚至简单的 Servlet 也能实现。但考虑到大多数课程设计和毕业设计的技术栈要求Spring Boot 是接受度最高的选择。Android 端不推荐用 WebView 套壳实现因为定位获取、相机扫码、设备标识采集这些原生能力在 WebView 里实现要么权限受限要么兼容性问题很多。2.3 为什么不用人脸识别要额外说明一点。很多人想到人脸识别签到但从实际工程角度看在课间签到这种“短时间、高并发”场景里人脸识别有两个瓶颈一是教室光线复杂摄像头分辨率不够会大幅影响识别率二是人脸特征数据的采集涉及学生隐私需要单独授权和合规说明很多学校并不能顺利推进。所以人脸识别更适合做“辅助核验”而不是“默认签到方式”。本文的重点还是二维码方案人脸识别可以作为后续优化方向来考虑。3. Android 客户端开发环境准备3.1 基础环境开发这个项目不需要太特殊的硬件条件一台能运行 Android Studio 的电脑加一台 Android 真机就足够了。开发前确认下面几项JDK 版本建议使用 17 或与 Android Studio 自带 JBR 匹配的版本。如果电脑上 JDK 版本混乱建议在项目级的build.gradle中明确指定 Java 版本。Android Studio使用较新的稳定版本即可本文代码没有依赖最新特性旧版本也能跑通。Android SDK建议安装 API 30 以上的 Platform 和对应 Build-Tools。新安装的 Android Studio 一般会提示缺失组件按提示安装即可。真机或模拟器模拟器适合验证界面和基本流程定位和扫码建议用真机测试。3.2 Gradle 同步与国内镜像配置新手项目很大概率卡在第一步新建项目后 Gradle 长时间同步不成功。这与网络原因有关不是代码问题。解决办法是在项目的build.gradle中配置国内镜像仓库。// 文件路径build.gradle项目级 buildscript { repositories { maven { url https://maven.aliyun.com/repository/public } maven { url https://maven.aliyun.com/repository/google } maven { url https://maven.aliyun.com/repository/gradle-plugin } google() mavenCentral() } } allprojects { repositories { maven { url https://maven.aliyun.com/repository/public } maven { url https://maven.aliyun.com/repository/google } google() mavenCentral() } }另外在gradle-wrapper.properties中可以手动指定一个下载速度更快的 Gradle 发行版地址distributionUrlhttps\://services.gradle.org/distributions/gradle-8.7-bin.zip如果你所在的网络环境访问 services.gradle.org 比较慢也可以使用腾讯或阿里云提供的 Gradle 镜像地址具体版本号以项目实际需要的 Gradle 版本为准。3.3 真机调试的准备工作以 Android 手机为例开启“开发者选项”和“USB 调试”后用数据线连接电脑Android Studio 一般会自动识别设备。部分国产手机在 USB 调试之外还需要额外开启“USB 安装”权限比如小米手机连接后需要在开发者选项里打开“USB 安装”和“USB 调试安全设置”否则安装 APP 时会提示失败。如果 Android Studio 始终识别不到设备先换一根支持数据传输的数据线试试这是最常见的低级坑。驱动问题多见于 Windows 系统可以打开设备管理器检查 ADB 接口是否识别正常。4. Android 客户端核心模块实现这一部分给出可运行的核心代码。为了控制篇幅我按模块拆分完整工程需要你根据项目结构自行组织。4.1 项目依赖配置在app/build.gradle中添加依赖// 文件路径app/build.gradle dependencies { implementation androidx.core:core-ktx:1.10.1 implementation androidx.appcompat:appcompat:1.6.1 implementation com.google.android.material:material:1.9.0 implementation androidx.constraintlayout:constraintlayout:2.1.4 // 网络请求 implementation com.squareup.retrofit2:retrofit:2.9.0 implementation com.squareup.retrofit2:converter-gson:2.9.0 implementation com.squareup.okhttp3:logging-interceptor:4.11.0 // 二维码生成与扫描 implementation com.google.zxing:core:3.5.2 implementation com.journeyapps:zxing-android-embedded:4.3.0 // 定位 implementation com.google.android.gms:play-services-location:21.0.1 // 生命周期 implementation androidx.lifecycle:lifecycle-runtime-ktx:2.6.1 }ZXing 的zxing-android-embedded库内部封装了扫码界面接入成本很低适合课设和演示项目。如果你希望扫码界面完全自定义再考虑直接用 CameraX 加 ZXing 核心库手动实现预览和解码这个后续可以单独写文章展开。4.2 AndroidManifest 权限声明!-- 文件路径app/src/main/AndroidManifest.xml -- uses-permission android:nameandroid.permission.INTERNET / uses-permission android:nameandroid.permission.CAMERA / uses-permission android:nameandroid.permission.ACCESS_FINE_LOCATION / uses-permission android:nameandroid.permission.ACCESS_COARSE_LOCATION /在 Android 6.0API 23及以上系统相机和定位权限需要在代码中动态申请。高版本 Android 还需要注意定位权限的细分ACCESS_FINE_LOCATION代表精确定位扫码和位置校验场景下建议申请这个权限。4.3 登录模块实现登录逻辑比较简单账号密码提交到服务端服务端返回用户信息和 token客户端把 token 保存到 SharedPreferences后续所有接口请求都携带这个 token。// 文件路径app/src/main/java/com/campus/sign/util/TokenManager.kt package com.campus.sign.util import android.content.Context import android.content.SharedPreferences class TokenManager(context: Context) { private val prefs: SharedPreferences context.getSharedPreferences(sign_prefs, Context.MODE_PRIVATE) fun saveToken(token: String, userId: Long, role: String) { prefs.edit() .putString(token, token) .putLong(user_id, userId) .putString(role, role) .apply() } fun getToken(): String? prefs.getString(token, null) fun getRole(): String? prefs.getString(role, null) fun clear() { prefs.edit().clear().apply() } fun isLoggedIn(): Boolean !getToken().isNullOrEmpty() }保存 token 只是最简单的方式。生产级项目更推荐用 EncryptedSharedPreferences 加密存储避免 root 设备上被直接读出敏感信息。学生项目里用 SharedPreferences 做演示没问题但你要知道这个局限。4.4 二维码扫描与签到提交扫码签到的核心代码分为两步第一步是解析二维码内容第二步是把解析结果连同位置、设备信息一起提交到服务端。// 文件路径app/src/main/java/com/campus/sign/ui/ScanActivity.kt package com.campus.sign.ui import android.Manifest import android.content.pm.PackageManager import android.location.Location import android.os.Bundle import android.widget.Toast import androidx.activity.result.contract.ActivityResultContracts import androidx.appcompat.app.AppCompatActivity import androidx.core.content.ContextCompat import com.campus.sign.databinding.ActivityScanBinding import com.campus.sign.network.ApiClient import com.campus.sign.network.SignRequest import com.campus.sign.util.TokenManager import com.google.zxing.integration.android.IntentIntegrator import retrofit2.Call import retrofit2.Callback import retrofit2.Response class ScanActivity : AppCompatActivity() { private lateinit var binding: ActivityScanBinding private lateinit var tokenManager: TokenManager private val locationPermissionLauncher registerForActivityResult(ActivityResultContracts.RequestPermission()) { granted - if (granted) startScan() else { Toast.makeText(this, 需要定位权限才能完成签到, Toast.LENGTH_SHORT).show() } } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) binding ActivityScanBinding.inflate(layoutInflater) setContentView(binding.root) tokenManager TokenManager(this) binding.btnScan.setOnClickListener { if (ContextCompat.checkSelfPermission( this, Manifest.permission.ACCESS_FINE_LOCATION ) ! PackageManager.PERMISSION_GRANTED ) { locationPermissionLauncher.launch(Manifest.permission.ACCESS_FINE_LOCATION) } else { startScan() } } } private fun startScan() { IntentIntegrator(this) .setDesiredBarcodeFormats(IntentIntegrator.QR_CODE) .setPrompt(扫描老师屏幕上的二维码) .setCameraId(0) .setBeepEnabled(false) .initiateScan() } override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { val result IntentIntegrator.parseActivityResult(requestCode, resultCode, data) if (result ! null) { if (result.contents null) { Toast.makeText(this, 已取消扫码, Toast.LENGTH_SHORT).show() } else { submitSign(result.contents) } } else { super.onActivityResult(requestCode, resultCode, data) } } private fun submitSign(qrContent: String) { val token tokenManager.getToken() ?: return val request SignRequest( qrCodeContent qrContent, latitude 0.0, longitude 0.0, deviceId android.provider.Settings.Secure.getString( contentResolver, android.provider.Settings.Secure.ANDROID_ID ) ) ApiClient.signApi.submitSign(Bearer $token, request) .enqueue(object : CallbackSignResult { override fun onResponse( call: CallSignResult, response: ResponseSignResult ) { if (response.isSuccessful response.body()?.code 200) { Toast.makeText(thisScanActivity, 签到成功, Toast.LENGTH_SHORT).show() finish() } else { val msg response.body()?.message ?: 签到失败 Toast.makeText(thisScanActivity, msg, Toast.LENGTH_LONG).show() } } override fun onFailure(call: CallSignResult, t: Throwable) { Toast.makeText(thisScanActivity, 网络异常: ${t.message}, Toast.LENGTH_LONG).show() } }) } }上面代码里的latitude和longitude目前是写死的 0.0这是一个简化的演示写法。真实项目需要调用FusedLocationProviderClient获取定位或者为了降低复杂度也可以使用 Android 自带的LocationManager。这里有一个关键点值得强调客户端上报的定位信息本质上不可完全信任因为用户可以安装模拟定位软件。服务端能做的是校验“位置偏差是否在合理范围内”而无法判断这个位置是不是伪造的。要想进一步提升可信度可以采集 WiFi 扫描结果或基站信息辅助判断这些属于增强方案后续会提到。4.5 网络请求封装为了方便调用用 Retrofit 定义签到接口// 文件路径app/src/main/java/com/campus/sign/network/SignApi.kt package com.campus.sign.network import retrofit2.Call import retrofit2.http.Body import retrofit2.http.Header import retrofit2.http.POST data class SignRequest( val qrCodeContent: String, val latitude: Double, val longitude: Double, val deviceId: String ) data class SignResult( val code: Int, val message: String, val data: Any? ) interface SignApi { POST(/api/sign/check) fun submitSign( Header(Authorization) token: String, Body request: SignRequest ): CallSignResult }Retrofit 的Call对象在执行网络请求时会自动处理线程切换回调方法运行在主线程所以可以直接在回调里更新 UI 或弹出 Toast。4.6 教师端二维码展示教师端相对简单进入“发起签到”页面点击按钮调接口获取二维码 Base64 字符串然后解码成 Bitmap 显示在 ImageView 上。// 文件路径app/src/main/java/com/campus/sign/ui/TeacherSignActivity.kt package com.campus.sign.ui import android.graphics.BitmapFactory import android.os.Bundle import android.util.Base64 import android.widget.Toast import androidx.appcompat.app.AppCompatActivity import com.campus.sign.databinding.ActivityTeacherSignBinding import com.campus.sign.network.ApiClient import com.campus.sign.network.QrResult import retrofit2.Call import retrofit2.Callback import retrofit2.Response class TeacherSignActivity : AppCompatActivity() { private lateinit var binding: ActivityTeacherSignBinding override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) binding ActivityTeacherSignBinding.inflate(layoutInflater) setContentView(binding.root) binding.btnCreateQr.setOnClickListener { loadQrCode() } } private fun loadQrCode() { ApiClient.signApi.createQrCode() .enqueue(object : CallbackQrResult { override fun onResponse(call: CallQrResult, response: ResponseQrResult) { val body response.body() if (response.isSuccessful body?.code 200) { val base64 body.data?.qrBase64 if (!base64.isNullOrEmpty()) { val imageBytes Base64.decode(base64, Base64.DEFAULT) val bitmap BitmapFactory.decodeByteArray(imageBytes, 0, imageBytes.size) binding.ivQrCode.setImageBitmap(bitmap) } } else { Toast.makeText(thisTeacherSignActivity, 二维码获取失败, Toast.LENGTH_SHORT).show() } } override fun onFailure(call: CallQrResult, t: Throwable) { Toast.makeText(thisTeacherSignActivity, 网络异常, Toast.LENGTH_SHORT).show() } }) } }老师在发起签到时可以设置二维码的有效期比如 60 秒自动过期。过期后学生即使扫到也会被服务端拒绝。这个“时间窗口”是整个防代签体系的第一层防护。5. 服务端接口设计与数据表设计服务端按 Spring Boot 结构组织。下面给出最核心的数据表设计和接口逻辑。5.1 数据库表设计签到系统至少需要四张表用户表、课程表、选课表、签到记录表。-- 文件路径sql/init.sql CREATE TABLE user ( id bigint(20) NOT NULL AUTO_INCREMENT, username varchar(50) NOT NULL COMMENT 学号/工号, password varchar(255) NOT NULL COMMENT 加密后的密码, real_name varchar(50) NOT NULL COMMENT 姓名, role tinyint(4) NOT NULL DEFAULT 2 COMMENT 1:教师 2:学生, device_id varchar(128) DEFAULT NULL COMMENT 最近登录设备ID, create_time datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_username (username) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE course ( id bigint(20) NOT NULL AUTO_INCREMENT, course_name varchar(100) NOT NULL, teacher_id bigint(20) NOT NULL, classroom varchar(100) DEFAULT NULL COMMENT 教室名称, latitude double DEFAULT NULL COMMENT 教室纬度, longitude double DEFAULT NULL COMMENT 教室经度, create_time datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE course_student ( id bigint(20) NOT NULL AUTO_INCREMENT, course_id bigint(20) NOT NULL, student_id bigint(20) NOT NULL, PRIMARY KEY (id), UNIQUE KEY uk_course_student (course_id, student_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE sign_record ( id bigint(20) NOT NULL AUTO_INCREMENT, sign_code varchar(64) NOT NULL COMMENT 二维码中的批次标识, course_id bigint(20) NOT NULL, student_id bigint(20) NOT NULL, sign_time datetime NOT NULL, latitude double DEFAULT NULL, longitude double DEFAULT NULL, device_id varchar(128) DEFAULT NULL, status tinyint(4) NOT NULL DEFAULT 0 COMMENT 0:正常 1:异常 2:迟到, PRIMARY KEY (id), KEY idx_course_time (course_id, sign_time), UNIQUE KEY uk_student_sign (student_id, sign_code) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;sign_record表里的uk_student_sign唯一约束非常关键它保证同一学生在同一个签到批次只能成功签到一次从数据库层面拦截重复提交。5.2 签到接口核心逻辑服务端签到接口是整套系统的“裁判员”// 文件路径sign-server/src/main/java/com/campus/sign/controller/SignController.java RestController RequestMapping(/api/sign) public class SignController { Autowired private SignService signService; PostMapping(/check) public ResultBoolean sign(RequestHeader(Authorization) String token, RequestBody SignRequest request) { // 1. 解析并验证 token // 2. 解析二维码内容拿到 signCode检查是否过期 // 3. 根据 signCode 查询对应的课程 // 4. 校验当前学生是否选了这个课 // 5. 校验位置偏差 // 6. 写入签到记录 return signService.doSign(token, request); } }二维码内容的设计可以这样// 文件路径sign-server/src/main/java/com/campus/sign/util/QrCodeGenerator.java public class QrCodeGenerator { private static final String SECRET campus-sign-secret; public static String generateSignCode(Long courseId, Long teacherId, long timestamp) { String raw courseId _ teacherId _ timestamp; String sign md5(raw SECRET).substring(0, 8); return raw _ sign; } public static boolean verifySignCode(String signCode, int validSeconds) { try { String[] parts signCode.split(_); long timestamp Long.parseLong(parts[2]); long now System.currentTimeMillis(); if (now - timestamp validSeconds * 1000L) { return false; // 二维码已过期 } String expected md5(parts[0] _ parts[1] _ timestamp SECRET) .substring(0, 8); return expected.equals(parts[3]); } catch (Exception e) { return false; } } }这里生成的signCode不只是随机数它把课程 ID、教师 ID、时间戳做了绑定并且附上了 MD5 签名。好处有三个别人无法伪造一个未知课程的 signCode。signCode 里的时间戳可以在服务端快速判断是否过期。二维码被转发后如果超过有效期服务端直接拒绝。需要提醒一下MD5 并不适合高安全场景真实项目建议使用 HMAC-SHA256。这里用 MD5 是为了减少代码复杂度方便你跑通整体逻辑。5.3 位置校验逻辑位置校验不是简单比一个点而是计算球面距离。常用方案是 Haversine 公式// 文件路径sign-server/src/main/java/com/campus/sign/util/GeoUtil.java public class GeoUtil { private static final double EARTH_RADIUS 6371.0; public static double distance(double lat1, double lng1, double lat2, double lng2) { double dLat Math.toRadians(lat2 - lat1); double dLng Math.toRadians(lng2 - lng1); double a Math.sin(dLat / 2) * Math.sin(dLat / 2) Math.cos(Math.toRadians(lat1)) * Math.cos(Math.toRadians(lat2)) * Math.sin(dLng / 2) * Math.sin(dLng / 2); double c 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); return EARTH_RADIUS * c * 1000; // 单位米 } }在签到业务中可以把允许的距离阈值配置为 100 米或者 200 米。这个阈值不能设置得太小因为手机 GPS 在室内会有 10 到 50 米的误差设置太严会导致正常学生频繁签到失败。6. 防代签的工程化设计这一部分是整套系统的真正亮点也是面试官或答辩老师一定会问的地方。6.1 动态刷新二维码老师端二维码不建议只生成一次而是应当每隔 10 到 20 秒自动刷新。刷新频率越高防截图转发能力越强但老师端屏幕闪烁也会影响体验。实践中的折中是 15 秒左右刷新一次加上 60 秒的服务端有效期校验。6.2 时间窗口校验服务端生成二维码时记录时间戳校验时比较当前时间与二维码时间的时间差。如果超过设定阈值返回“二维码已过期”。这里一定要使用服务端时间不能信任客户端上传的时间否则学生修改手机时间就可以绕过限制。6.3 设备指纹绑定首次登录时客户端上传 Android ID 或设备唯一标识服务端在用户表里保存该字段。签到时提交的 deviceId 必须和登录时一致否则判定“账号异常”。这个方案也有局限性Android ID 在某些定制 ROM 上可能不稳定而且用户刷机后 Android ID 会变化。如果想做得更健壮可以组合多种设备特征生成指纹比如Android ID Build.MODEL Build.BRAND。6.4 位置与时间交叉校验把位置阈值和时间窗口放在一起校验能挡住大部分代签行为。举个例子如果张三人在宿舍让室友李四拿着张三账号在教室扫码设备指纹校验会发现登录设备与签到设备不一致如果张三直接把自己的手机交给李四设备指纹和账号是匹配的但服务端记录里会留下“同一设备在极短时间内出现在不同地点”的可疑数据。这就是交叉校验的价值。6.5 异常行为检测服务端可以定期分析签到记录发现以下异常模式同一设备 ID 短时间内在不同课程签到。同一学生的签到位置长期固定偏移。某一门课平均签到时间极短且成功率极高。这些不是实时拦截手段而是课后分析手段用于给辅导员和老师提供参考依据。需要明确的是没有任何方案能 100% 防止代签。技术手段的意义是提高作弊成本让代签需要付出的代价大于自己出勤。一个需要换手机、提前沟通、冒着位置风险才能完成的代签绝大多数学生不会去做。7. 运行结果与效果验证搭建好环境后启动服务端和 Android 客户端按以下流程验证老师账号登录进入课程详情页点击“发起签到”。服务端生成二维码老师端 ImageView 显示二维码图片。学生账号登录点击“扫码签到”扫描老师端二维码。客户端弹出“签到成功”提示同时服务端日志打印签到记录。再次用同一账号扫同一二维码服务端返回“重复签到”错误。验证清单如下场景预期结果失败时的处理方向学生登录后扫码签到返回签到成功检查服务端日志和数据库同一个学生重复扫同一二维码返回“该批次已签到”检查唯一索引和签到查询逻辑二维码过期后扫描返回“二维码已过期”检查服务端系统时间与二维码时间戳未选课学生扫描返回“未选该课程”检查 course_student 表数据位置偏离教室超过阈值返回“不在签到范围”检查经纬度上传和 GeoUtil 计算如果签到失败先看服务端日志。Spring Boot 控制台会打印完整的异常堆栈按错误码逐层排查比直接改代码更高效。8. 常见问题与排查思路8.1 Android Studio 新建项目一直下载 Gradle这是最普遍的环境问题。出现这个现象原因基本是gradle-wrapper.properties里指向的下载地址无法访问或速度过慢。解决方案是换成国内镜像地址或者从本机已有的 Gradle 版本直接配置。8.2 提示“SDK component was not installed: Android SDK Build-Tools xx”新装的 Android Studio 往往只安装了默认版本的 Build-Tools项目编译版本高于已安装版本时就会报这个错。在 SDK Manager 中安装对应版本号即可。8.3 扫码中文内容乱码ZXing 扫码后出现中文乱码一般不是摄像头问题而是二维码编码字符集设置不一致。服务端生成二维码时指定 UTF-8客户端 ZXing 解码后也用 UTF-8 重新编码字符串。如果二维码内容包含课程名称等中文信息这个问题大概率会出现。8.4 模拟器无法定位Android 模拟器默认位置在美国旧金山如果你没有在模拟器扩展控制中手动设置经纬度位置校验一定会失败。可以通过模拟器的“Location”面板手动输入教室经纬度来测试。8.5 高版本 Android 保存文件失败如果你需要保存签到记录或二维码到本地在高版本 Android 上会出现file:///storage/emulated/0/...类似的访问受限问题原因是系统强制分区存储。解决方案是使用 MediaStore 或应用专属外部目录不要直接向公共存储目录写文件。8.6 小米手机连接电脑后 Android Studio 不识别小米手机默认不开启“USB 调试”即使开了也可能需要登录小米账号才能启用“USB 安装”。在开发者选项里开启“USB 安装”并把 USB 连接模式改为“文件传输”识别率会大幅提升。9. 最佳实践与工程建议9.1 客户端与服务端的职责边界客户端要“轻”服务端要“重”。所有签到规则必须写在服务端客户端只负责采集和展示。如果规则写在客户端APK 一旦被反编译整个防代签体系就会失效。9.2 权限最小化申请相机和定位权限时必须向用户说明用途。不要申请通讯录、短信等与签到无关的权限。在某些高校的应用审核流程中权限越界会直接被拒绝上架。9.3 接口异常处理服务端接口需要统一返回结构比如{ code, message, data }。客户端根据 code 判断业务状态而不是依赖 HTTP 状态码。很多签到失败不是网络错误而是业务错误码比如“重复签到”“课程不存在”“位置超限”。9.4 安全基线密码存储必须使用 BCrypt 加密不能明文保存。token 推荐 JWT并设置合理的过期时间。生产环境必须启用 HTTPS 传输。服务端接口需要做防刷限流避免恶意请求打满数据库。9.5 数据库优化方向签到记录会随着学期推进快速增长建议按学期或按课程分表。查询学生历史签到时student_id sign_time联合索引很有必要。sign_record表的唯一索引是防止重复签到的最后一道防线一定不能丢掉。9.6 工程结构建议Android 端建议按 MVVM 分层Activity/Fragment 只处理 UIViewModel 管理状态Repository 封装数据源。这样后续接人脸识别、离线签到、推送通知都不需要大面积改代码。10. 总结与后续学习方向这套基于 Android 的课间签到管理系统核心价值在于把“签到”这个看似简单的事务拆成了身份认证、动态令牌、时间窗口、位置偏差、设备绑定、异常分析几个独立层次。你在答辩或项目展示时不要只说“我用了 RecyclerView 展示数据”而是要把“防代签设计”作为主线讲清楚每个校验手段解决什么问题、存在什么局限、还可以怎样演进。下一步可以深入的方向有三个第一把扫码换成蓝牙感知或 WiFi RTT 测距实现“进入教室自动签到”体验会更好但需要更多硬件适配工作。第二引入人脸识别做人脸活体校验在二维码方案之上增加一重身份确认。第三把 Android 客户端迁移为 Flutter 或 uni-app同时覆盖 iOS 和 Android但定位、扫码、设备指纹的原生调用层需要重新适配。课间签到系统做出来并不难难的是把每一个校验环节想清楚、做扎实。希望这篇文章能帮你少走弯路也欢迎收藏备用实践过程中遇到问题可以再翻回来看排查部分。
返回列表