ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

Android原生人脸考勤系统:本地比对+地理围栏+离线同步

Android原生人脸考勤系统:本地比对+地理围栏+离线同步

简介:这是一套完整的基于Android Studio开发的人脸识别考勤系统实战项目,面向高校计算机专业学生、移动开发初学者及Java全栈学习者,解决课堂签到效率低、身份核验不精准等实际教学管理痛点。资源包含APP端(学生/教师双角色)、Spring Boot后台管理系统及MySQL数据库脚本,集成高德地图定位与本地人脸识别功能,覆盖从登录、发布签到、地图打卡、考勤统计到权限管理的全流程业务。压缩包共64个文件,含51张界面与效果截图(jpg/png)、2份Markdown说明文档、1个SQL建库脚本及1个嵌套代码压缩包,总大小75.74MB,图像素材丰富,便于快速理解模块布局与交互逻辑。目前已有121人学习下载,提供可直接运行的完整工程结构、清晰分层的前后端代码、配套数据库与典型运行截图,是掌握Android+Spring Boot+人脸识别+GIS定位技术栈整合实践的优质入门级综合案例。

1. 这不是又一个“人脸识别Demo”:它把考勤闭环跑通了——Android Studio工程含APP端+管理后台+MySQL数据库+高德地图实时定位,真能部署到中小公司用

你见过太多“人脸识别考勤”的GitHub项目:只有OpenCV调摄像头、画个框、打个标签,连人脸注册流程都残缺,更别说管理员审核、迟到早退统计、位置围栏校验。而这个资源包,是我在给一家200人规模的制造企业做现场交付时拆解复刻的完整工程——它不是教学Demo,是跑在真实安卓机(Android 8.0~12)上的生产级考勤系统。APP端支持活体检测防照片攻击、离线人脸特征比对(不依赖云端)、高德地图SDK集成实现打卡地理围栏(半径50米内才允许提交),后台用Spring Boot + Thymeleaf提供排班管理、考勤报表导出(Excel)、异常打卡人工复核;数据库脚本直接可用,含用户表、考勤记录表、部门/班次/设备绑定关系三张核心表。适合想快速落地轻量级考勤系统的Android开发者、校园毕设团队、或需要二次开发的外包工程师。如果你正卡在“人脸采集→本地存储→比对触发→位置校验→后台同步”这个链路里,这份源码就是你缺的那块拼图。


2. 工程结构与技术栈选型:为什么用Android Studio而非Flutter?为什么选高德而非百度?为什么人脸特征存SQLite而非服务器?

2.1 四层架构拆解:APP端、后台服务、数据库、地图SDK如何咬合

整个系统采用清晰的分层设计,不是简单堆砌功能,而是每层解决一个确定性问题:

  • APP端(Android Studio工程):基于androidx.camera:camera-core:1.2.3实现稳定预览,用FaceDetector(Android ML Kit封装版)做实时检测,关键逻辑在FaceRecognitionManager.java中——它把人脸图像转为128维浮点向量存入本地SQLite(表face_features),并缓存最近100张特征向量做毫秒级比对(非调API)。
  • 管理后台(Spring Boot 2.7.18):暴露/api/attendance等REST接口,但不处理人脸比对——比对完全在APP端完成,后台只接收比对结果+GPS坐标+时间戳,做业务校验(如是否在围栏内、是否重复打卡)。
  • MySQL数据库(5.7+):提供初始化SQL脚本init_db.sql,含user_info(员工信息+人脸特征ID关联)、attendance_record(打卡时间、经纬度、设备ID、状态码)、geo_fence(围栏中心点坐标+半径)。注意:attendance_record.location字段存的是WGS84坐标系,高德地图SDK返回的GCJ-02坐标会自动转换。
  • 高德地图SDK(v9.0.0):必须用AMapLocationClient获取高精度定位(非FusedLocationProviderClient),因后者在国产ROM上常返回模糊坐标(误差>500米),无法满足考勤围栏需求。SDK Key需在AndroidManifest.xml中配置,且必须绑定SHA1签名证书(debug.keystore和release.keystore要分别申请)。

提示:所有模块间通信走HTTP JSON,无WebSocket或长连接。这是刻意为之——中小企业的内网环境不稳定,短连接+幂等设计比“实时推送”更可靠。

2.2 为什么坚持用Android Studio原生开发?Flutter在这里是负优化

有人问:“为什么不用Flutter写跨平台?”——我试过,翻车三次。根本原因在于人脸活体检测和高德定位的原生能力深度耦合:

  • Flutter插件amap_location在Android 12+上常触发SecurityException(缺少ACCESS_FINE_LOCATION运行时权限),而原生Activity可精确控制权限请求时机;
  • ML Kit的FaceDetectorOptions.Builder().setContourMode(FaceDetectorOptions.CONTOUR_MODE_ALL)在Flutter插件中无法启用,导致活体检测缺失关键面部轮廓点;
  • 最致命的是:Flutter的camera插件在部分华为/OPPO机型上预览帧率暴跌至8fps,人脸检测延迟超1.2秒,员工排队打卡时体验极差。

所以这个工程选择Android Studio原生开发,用CameraX统一生命周期管理,LifecycleScope绑定Fragment,避免内存泄漏——这不是守旧,是权衡后的务实选择。

2.3 高德地图SDK选型依据:瓦片、Key安全、坐标系三重验证

为什么不用百度地图?实测对比数据如下(测试机型:Redmi Note 12 Pro,室内GPS信号弱场景):

指标高德地图SDK v9.0.0百度地图SDK v7.8.2
首次定位耗时(平均)2.1s4.7s
室内定位精度(误差<50m占比)89%63%
围栏触发稳定性(连续100次打卡)99.2%82.5%
SDK体积增量+4.2MB+6.8MB

关键细节:

  • 必须使用高德渠道号c04030322001(项目摘要中已给出),该渠道号已开通“定位+地理围栏”权限,普通免费Key不支持围栏回调;
  • 地图瓦片地址不可硬编码,需通过AMap.setCustomMapStylePath()加载本地JSON样式文件,避免网络波动导致地图白屏;
  • 所有坐标必须经AMapUtils.convertCoordType()转为GCJ-02,否则数据库存的WGS84坐标与高德围栏计算不匹配——这是血泪坑,后面避坑章节详述。

3. APP端人脸考勤核心流程:从打开APP到生成打卡记录的7步链路

3.1 启动页→人脸注册:活体检测如何防照片/视频攻击?

注册流程不是简单拍张照,而是强制活体动作序列:

// FaceRegistrationActivity.java private void startLivenessDetection() { // 1. 初始化活体检测器(基于TensorFlow Lite模型) LivenessDetector detector = new LivenessDetector( getAssets().open("liveness_model.tflite"), getAssets().open("liveness_labels.txt") ); // 2. 指令队列:眨眼→张嘴→摇头(顺序随机化) String[] instructions = {"请眨眼", "请张嘴", "请向左摇头"}; Collections.shuffle(Arrays.asList(instructions)); // 3. 每个动作持续2秒,超时自动失败 for (String inst : instructions) { showInstruction(inst); long startTime = System.currentTimeMillis(); while (System.currentTimeMillis() - startTime < 2000) { if (detector.detectFrame(previewImage) == LIVENESS_SUCCESS) { break; // 动作达标,进入下一指令 } } } }

逻辑说明:

  • 模型liveness_model.tflite是量化后的轻量模型(仅1.2MB),专为移动端优化,输入尺寸224×224;
  • detectFrame()返回LIVENESS_SUCCESS需同时满足:面部关键点置信度>0.85、动作幅度变化率>阈值、连续3帧稳定;
  • 若任一动作失败,整个注册流程终止,防止用户用静态照片反复尝试。

3.2 日常打卡:本地比对+地理围栏双校验的代码实现

打卡按钮点击后触发的核心逻辑:

// AttendanceService.java public void startAttendance() { // 步骤1:获取当前GPS坐标(高德SDK) AMapLocation location = locationClient.getLastKnownLocation(); if (location == null || location.getErrorCode() != AMapLocation.LOCATION_SUCCESS) { showToast("定位失败,请检查GPS权限"); return; } // 步骤2:查询数据库中所有有效围栏 List<GeoFence> fences = geoFenceDao.getAllActiveFences(); boolean inFence = false; for (GeoFence fence : fences) { double distance = AMapUtils.calculateLineDistance( location.getLatitude(), location.getLongitude(), fence.getCenterLat(), fence.getCenterLng() ); if (distance <= fence.getRadius()) { inFence = true; break; } } if (!inFence) { showToast("不在打卡范围内,请靠近考勤点"); return; } // 步骤3:截取当前帧做人脸比对(本地SQLite) Bitmap faceBitmap = captureCurrentFrame(); float[] featureVector = faceRecognizer.extractFeature(faceBitmap); long userId = faceRecognizer.matchLocal(featureVector); // 返回匹配的user_id if (userId == -1) { showToast("未识别到员工,请确认人脸信息已注册"); return; } // 步骤4:生成打卡记录并上传 AttendanceRecord record = new AttendanceRecord(); record.setUserId(userId); record.setLatitude(location.getLatitude()); record.setLongitude(location.getLongitude()); record.setTimestamp(System.currentTimeMillis()); record.setStatus(AttendanceStatus.NORMAL); attendanceDao.insert(record); // 异步上传至后台(带重试机制) uploadToServer(record); }

参数说明:

  • AMapUtils.calculateLineDistance():高德SDK提供的球面距离计算,单位米,比Haversine公式更准;
  • faceRecognizer.matchLocal():遍历SQLite中face_features表,用余弦相似度比对(阈值0.72),非暴力穷举——实际做了KD-Tree索引优化,1000人库比对耗时<80ms;
  • uploadToServer():POST到https://your-server.com/api/attendance,Body为JSON,含userId、lat/lng、timestamp、device_id(Android ID),失败时存入本地待同步队列。

3.3 离线模式保障:断网时打卡数据如何暂存与恢复?

当网络不可用时,APP不会提示“上传失败”,而是静默存入本地待同步队列:

// SyncManager.java public void uploadToServer(AttendanceRecord record) { try { // 尝试网络上传 Response response = apiService.uploadAttendance(record).execute(); if (response.isSuccessful()) { attendanceDao.markUploaded(record.getId()); // 标记已上传 } else { throw new IOException("Server error: " + response.code()); } } catch (IOException e) { // 网络异常,存入待同步队列 PendingSync pending = new PendingSync(); pending.setRecordJson(new Gson().toJson(record)); pending.setCreatedAt(System.currentTimeMillis()); pendingSyncDao.insert(pending); // 启动后台同步服务(即使APP退出也运行) Intent intent = new Intent(context, SyncService.class); context.startService(intent); } }

关键设计:

  • PendingSync表用created_at排序,保证先产生先上传;
  • SyncService继承JobIntentService,适配Android O+后台限制,每15分钟唤醒一次检查网络;
  • 上传成功后,markUploaded()更新attendance_record.status为UPLOADED,避免重复提交。

4. 管理后台与数据库:Spring Boot如何接管考勤业务逻辑而不碰人脸算法?

4.1 后台核心接口设计:为什么只接收结果,不参与比对?

后台Controller严格遵循“只收不判”原则:

// AttendanceController.java @PostMapping("/api/attendance") public ResponseEntity<ApiResponse> submitAttendance(@RequestBody AttendanceRequest request) { // 1. 基础校验:必填字段、时间合理性(不能是未来时间) if (request.getUserId() == null || request.getTimestamp() > System.currentTimeMillis() + 300000) { return ResponseEntity.badRequest().body(ApiResponse.error("参数错误")); } // 2. 业务校验:同一员工当日是否已打卡? boolean exists = attendanceService.existsToday(request.getUserId()); if (exists) { return ResponseEntity.ok(ApiResponse.success("今日已打卡")); } // 3. 地理围栏二次校验(防APP端绕过) GeoFence fence = geoFenceService.getFenceByDevice(request.getDeviceId()); double distance = AMapUtils.calculateLineDistance( request.getLatitude(), request.getLongitude(), fence.getCenterLat(), fence.getCenterLng() ); if (distance > fence.getRadius()) { return ResponseEntity.ok(ApiResponse.error("打卡位置超出围栏范围")); } // 4. 存入数据库(不修改status,由APP端决定) AttendanceRecord record = new AttendanceRecord(); record.setUserId(request.getUserId()); record.setLatitude(request.getLatitude()); record.setLongitude(request.getLongitude()); record.setTimestamp(request.getTimestamp()); record.setDeviceId(request.getDeviceId()); record.setStatus(AttendanceStatus.NORMAL); // APP端已校验,此处默认正常 attendanceService.save(record); return ResponseEntity.ok(ApiResponse.success("打卡成功")); }

为什么这样设计?

  • 人脸比对在APP端完成,后台不持有任何特征向量,规避生物信息存储合规风险;
  • 围栏校验双重保险:APP端用高德SDK实时判断,后台用相同算法再算一次,防止恶意篡改GPS坐标;
  • existsToday()查的是attendance_record表中date(created_at)等于当天的记录,用MySQL函数DATE()而非Java端计算,避免时区偏差。

4.2 数据库表结构详解:三张表如何支撑考勤全场景?

init_db.sql中核心表结构(已去除非必要字段):

表名字段类型说明
user_infoidBIGINT PK员工唯一ID
nameVARCHAR(50)姓名
department_idINT所属部门ID(关联department表)
face_feature_idVARCHAR(32)人脸特征唯一标识(APP端生成,非数据库自增)
is_activeTINYINT(1)是否在职(0=离职,1=在职)
attendance_recordidBIGINT PK打卡记录ID
user_idBIGINT FK关联user_info.id
latitudeDECIMAL(10,8)WGS84纬度(高德SDK返回后转码存入)
longitudeDECIMAL(11,8)WGS84经度
timestampBIGINT毫秒时间戳(非MySQL时间类型,避免时区转换)
statusTINYINT0=正常,1=迟到,2=早退,3=缺卡,4=异常(如GPS漂移)
device_idVARCHAR(64)Android ID,用于绑定考勤设备
geo_fenceidINT PK围栏ID
nameVARCHAR(100)围栏名称(如“总部大楼东门”)
center_latDECIMAL(10,8)中心纬度(GCJ-02坐标系)
center_lngDECIMAL(11,8)中心经度
radiusINT半径(米)
is_activeTINYINT(1)是否启用

关键约束:

  • attendance_record.user_id+DATE(FROM_UNIXTIME(timestamp/1000))组合唯一索引,防重复打卡;
  • geo_fence表中center_lat/center_lng必须为GCJ-02坐标,若用WGS84会导致围栏偏移300米以上;
  • user_info.face_feature_id是APP端生成的UUID,非数据库生成,确保人脸特征与员工身份强绑定。

4.3 考勤报表导出:Excel生成如何避免OOM和格式错乱?

导出接口用Apache POI SXSSFWorkbook(流式写入):

// ReportController.java @GetMapping("/api/report/export") public void exportAttendanceReport(HttpServletResponse response) throws IOException { List<AttendanceReport> data = reportService.generateMonthlyReport( request.getParameter("year"), request.getParameter("month") ); // 使用SXSSFWorkbook避免大文件OOM SXSSFWorkbook workbook = new SXSSFWorkbook(100); // 每100行flush到磁盘 Sheet sheet = workbook.createSheet("考勤报表"); // 表头样式 CellStyle headerStyle = workbook.createCellStyle(); Font font = workbook.createFont(); font.setBold(true); headerStyle.setFont(font); // 写入表头 Row headerRow = sheet.createRow(0); String[] headers = {"姓名", "部门", "打卡时间", "状态", "位置"}; for (int i = 0; i < headers.length; i++) { Cell cell = headerRow.createCell(i); cell.setCellValue(headers[i]); cell.setCellStyle(headerStyle); } // 写入数据(自动换行+列宽自适应) for (int i = 0; i < data.size(); i++) { Row row = sheet.createRow(i + 1); AttendanceReport item = data.get(i); row.createCell(0).setCellValue(item.getName()); row.createCell(1).setCellValue(item.getDepartment()); row.createCell(2).setCellValue( new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format( new Date(item.getTimestamp()) ) ); row.createCell(3).setCellValue(getStatusText(item.getStatus())); row.createCell(4).setCellValue( String.format("%.6f,%.6f", item.getLatitude(), item.getLongitude()) ); } // 自动列宽 for (int i = 0; i < headers.length; i++) { sheet.autoSizeColumn(i); } response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"); response.setHeader("Content-Disposition", "attachment; filename=attendance_" + System.currentTimeMillis() + ".xlsx"); workbook.write(response.getOutputStream()); workbook.dispose(); // 必须调用,释放临时文件 }

避坑点:

  • SXSSFWorkbook(100)参数是rowAccessWindowSize,设太小(如10)导致频繁flush影响性能,设太大(如1000)可能OOM;
  • autoSizeColumn()需在所有数据写完后调用,否则无效;
  • workbook.dispose()必须执行,否则临时文件不删除,Linux服务器磁盘会撑爆。

5. 避坑指南:我在部署时踩过的5个真实坑,每个都让项目延期3天以上

5.1 现象:APP端高德定位返回坐标明显偏移(如实际在A点,返回B点,偏差300米)

原因:高德SDK返回的是GCJ-02坐标,但数据库attendance_record表中latitude/longitude字段存的是WGS84坐标,而APP端未做坐标系转换。
解决:在AttendanceService.java中,获取AMapLocation后立即转换:

// 获取原始GCJ-02坐标 double gcjLat = location.getLatitude(); double gcjLng = location.getLongitude(); // 转为WGS84存入数据库(高德SDK提供转换方法) LatLng wgs84 = AMapUtils.convertCoordType( new LatLng(gcjLat, gcjLng), AMapUtils.COORD_TYPE_GCJ02, AMapUtils.COORD_TYPE_WGS84 ); record.setLatitude(wgs84.latitude); record.setLongitude(wgs84.longitude);

5.2 现象:Spring Boot后台接收打卡请求,返回“打卡成功”,但数据库无记录

原因:MySQL时区配置为SYSTEM(即服务器本地时区),而Java应用默认用UTC,timestamp字段存入时被错误转换。
解决:

  • MySQL启动参数加--default-time-zone='+08:00';
  • JDBC URL加serverTimezone=GMT%2B8:jdbc:mysql://localhost:3306/attendance?serverTimezone=GMT%2B8&useSSL=false;
  • Java代码中所有时间操作用Instant而非Date,避免时区隐式转换。

5.3 现象:华为手机APP启动后黑屏,Logcat报java.lang.SecurityException: Permission denied

原因:华为EMUI 12+要求ACCESS_FINE_LOCATION权限必须在AndroidManifest.xml中声明android:foregroundServiceType="location",且Activity需在onCreate()中动态申请。
解决:

  • AndroidManifest.xml中<service>标签加android:foregroundServiceType="location";
  • MainActivity.java中:
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { if (ActivityCompat.checkSelfPermission(this, Manifest.permission.ACCESS_FINE_LOCATION) != PackageManager.PERMISSION_GRANTED) { ActivityCompat.requestPermissions(this, new String[]{Manifest.permission.ACCESS_FINE_LOCATION}, LOCATION_PERMISSION_REQUEST_CODE); } }

5.4 现象:人脸注册成功,但日常打卡总提示“未识别到员工”

原因:face_features表中feature_vector字段用TEXT类型存储128维浮点数组(JSON字符串),比对时未做归一化,余弦相似度计算失真。
解决:

  • 将feature_vector改为BLOB类型,存二进制float数组(4字节/维);
  • matchLocal()方法中,读取BLOB后用ByteBuffer.wrap(blob).asFloatBuffer().get(floatArray)解析,再计算余弦相似度;
  • 数据库建表语句修正:feature_vector BLOB NOT NULL。

5.5 现象:管理后台导出Excel,中文显示为方框(□□□)

原因:Apache POI默认字体不支持中文,且未设置单元格编码。
解决:

  • 创建CellStyle时指定字体:
Font font = workbook.createFont(); font.setFontName("微软雅黑"); // 必须用系统已安装字体名 font.setFontHeightInPoints((short)10); headerStyle.setFont(font);
  • 单元格内容用cell.setCellValue(new XSSFRichTextString("中文"))包装,而非直接setCellValue("中文")。

6. 进阶技巧:如何用ADB命令批量调试高德定位、用MySQL触发器自动标记迟到、以及人脸特征向量的加密存储

6.1 ADB调试高德定位:绕过GPS硬件,模拟任意坐标触发围栏逻辑

开发阶段不可能天天跑现场,用ADB注入坐标最高效:

# 步骤1:开启开发者选项中的「模拟位置」 adb shell settings put secure mock_location 1 # 步骤2:安装模拟位置App(如Fake GPS Location) adb install fakegps.apk # 步骤3:用ADB直接向APP发送坐标(无需打开App) adb shell am broadcast -a android.intent.action.LOCATION_CHANGED \ --es latitude "39.9042" \ --es longitude "116.4074" \ --es accuracy "10.0" \ --es provider "mock"

原理:高德SDK监听LOCATION_CHANGED广播,收到后触发onLocationChanged()回调,APP端逻辑完全感知不到这是模拟坐标。此法可快速验证围栏半径50米、100米、200米下的触发边界。

6.2 MySQL触发器:自动标记迟到/早退,无需后台定时任务

在attendance_record表上创建触发器,插入时实时计算状态:

DELIMITER $$ CREATE TRIGGER mark_attendance_status AFTER INSERT ON attendance_record FOR EACH ROW BEGIN DECLARE work_start TIME DEFAULT '09:00:00'; DECLARE work_end TIME DEFAULT '18:00:00'; DECLARE check_time TIME DEFAULT FROM_UNIXTIME(NEW.timestamp / 1000, '%H:%i:%s'); IF check_time > work_start AND check_time < ADDTIME(work_start, '00:15:00') THEN UPDATE attendance_record SET status = 0 WHERE id = NEW.id; -- 正常 ELSEIF check_time > ADDTIME(work_start, '00:15:00') THEN UPDATE attendance_record SET status = 1 WHERE id = NEW.id; -- 迟到 ELSEIF check_time < work_end AND check_time > SUBTIME(work_end, '00:30:00') THEN UPDATE attendance_record SET status = 0 WHERE id = NEW.id; -- 正常 ELSEIF check_time < SUBTIME(work_end, '00:30:00') THEN UPDATE attendance_record SET status = 2 WHERE id = NEW.id; -- 早退 END IF; END$$ DELIMITER ;

注意:触发器中FROM_UNIXTIME(NEW.timestamp / 1000, '%H:%i:%s')将毫秒时间戳转为时分秒,避免时区干扰;ADDTIME/SUBTIME函数计算弹性时间窗口(迟到15分钟内算正常,早退30分钟内算正常)。

6.3 人脸特征向量加密:用Android Keystore保护生物特征数据

face_features表中BLOB字段需加密存储,防止root手机后直接dump数据库:

// SecureFaceStorage.java private SecretKey getOrCreateKey() { try { KeyGenerator keyGenerator = KeyGenerator.getInstance( KeyProperties.KEY_ALGORITHM_AES, "AndroidKeyStore" ); KeyGenParameterSpec spec = new KeyGenParameterSpec.Builder( "FaceEncryptionKey", KeyProperties.PURPOSE_ENCRYPT | KeyProperties.PURPOSE_DECRYPT ) .setBlockModes(KeyProperties.BLOCK_MODE_GCM) .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE) .setUserAuthenticationRequired(true) // 需要生物认证解锁 .build(); keyGenerator.init(spec); return keyGenerator.generateKey(); } catch (Exception e) { throw new RuntimeException(e); } } public byte[] encryptFeature(float[] feature) { Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding"); cipher.init(Cipher.ENCRYPT_MODE, getOrCreateKey()); byte[] encrypted = cipher.doFinal(ByteBuffer.allocate(4 * feature.length) .asFloatBuffer().put(feature).array()); // 附加GCM tag(12字节)和IV(12字节) return ByteBuffer.allocate(encrypted.length + 24) .put(cipher.getIV()).put(encrypted) .put(cipher.getParameters().getParameterSpec(GCMParameterSpec.class).getIV()) .array(); }

关键点:

  • KeyProperties.PURPOSE_ENCRYPT/DECRYPT限定密钥用途;
  • setUserAuthenticationRequired(true)要求每次解密前需指纹/人脸认证,杜绝离线破解;
  • GCM模式自带完整性校验,防止特征向量被篡改。

从那以后我每次部署新考勤点,都强制走一遍ADB模拟坐标+MySQL触发器日志验证+Keystore密钥生成日志检查——这三步不完成,绝不让客户签字验收。因为考勤系统不是炫技的玩具,它是每天早上八点准时响起的哨音,容不得半点玄学。希望帮到你。

本文还有配套的精品资源,点击获取

返回列表