首页 / 资讯中心 / 文章详情

喵呜打卡项目总结

喵呜打卡项目总结 ★ FEATURED ARTICLE
喵呜打卡一个 NFC 碰一碰 钉钉考勤的全栈项目实战总结一、项目简介喵呜打卡是一套面向企业内部考勤场景的完整应用由一个 uni-app 安卓 APP 和一个 Go 后端组成。员工用手机碰一下贴在签到点的 NFC 卡片APP 读取卡片出厂序列号完成打卡本地记录落库同时后台异步把打卡流水同步到钉钉考勤。在此基础上还实现了钉钉一键登录、考勤记录查询、多组织凭证隔离等功能。整个过程中最有价值的不是功能本身而是 NFC 读卡、钉钉登录、原生插件、权限与签名这几个环节踩过的坑这篇文章把这些问题和解决思路完整记录下来。前端项目代码链接https://github.com/bigxinyuan222/NFC-TAG后端项目代码链接https://github.com/bigxinyuan222/NFC-Server二、整体架构┌─────────────────────────────┐ ┌──────────────────────────────┐ │ 安卓 APP前端 │ │ 后端服务Go │ │ uni-app Vue3 HBuilderX │ │ net/http MySQL JWT │ │ │ │ │ │ NFC 读卡 / 钉钉登录 / 打卡页 │ HTTP │ 登录注册 / 签到 / 记录查询 │ │ 记录页 / 个人中心 / 设置 │ ────── │ 钉钉 worker / 通讯录同步 │ └──────────────┬──────────────┘ └──────────────┬───────────────┘ │ 碰一碰 │ 异步队列 v v ┌─────────────┐ ┌─────────────────┐ │ NFC 实体卡片 │ │ 钉钉开放平台 API │ └─────────────┘ └─────────────────┘前端只负责交互和采集所有业务校验、用户匹配、数据持久化都在后端。后端与钉钉之间通过异步队列解耦打卡接口不等待钉钉返回避免外部接口拖慢主流程。三、技术栈层技术前端框架uni-app、Vue3前端能力plus.android 原生桥接、UTS 插件、uni 本地存储开发/打包HBuilderX、云打包、自定义调试基座后端Go 1.21标准库 net/http不使用 Web 框架数据库MySQL 8.0utf8mb4鉴权JWTHS25672 小时、PBKDF2 口令哈希部署Docker Compose、云服务器、内嵌 OpenAPI四、核心功能账号注册登录口令 PBKDF2 哈希存储登录下发 JWT。NFC 碰一碰读取卡片 UID卡号白名单 签到点校验后写入打卡记录。钉钉一键登录授权后自动匹配系统用户。打卡记录分页查询、月度考勤统计。本地打卡流水异步同步钉钉考勤失败指数退避重试。多组织乐知教育、乐知数字凭证隔离各自独立匹配用户。个人资料编辑、系统设置、关于页面。五、难点一NFC 碰一碰读取卡片出厂 UID5.1 先分清两种碰卡场景碰卡启动 APPAPP 没在前台系统识别到 NFC 后根据 manifest 里的 intent-filter 把 APP 拉起来卡片数据通过启动 intent 带进来。APP 在前台时碰卡APP 已经打开需要通过前台调度Foreground Dispatch抢占 NFC 事件监听newintent。如果只处理其中一种就会出现「能碰卡打开 APP但读不到 UID」或者「前台碰卡没反应」的问题。最终实现里两种场景都做了处理。5.2 通过 plus.android 桥接拿到原生能力uni-app 在 APP 端可以通过plus.android.importClass直接调用 Android 原生类不需要写原生工程。获取 NFC 适配器的过程constactivityplus.android.runtimeMainActivity()constNfcAdapterplus.android.importClass(android.nfc.NfcAdapter)constadapterNfcAdapter.getDefaultAdapter(activity)if(!adapter)return{error:当前设备不支持 NFC}if(!adapter.isEnabled())return{error:请先在系统设置中开启 NFC}这里依次区分了三种错误设备没有 NFC 硬件、NFC 开关没打开、以及非 APP 平台每一种都给出明确提示而不是静默失败。5.3 从 intent 中取出 Tag 并解析 UID系统把卡片信息放在 intent 的EXTRA_TAG里先判断 action 是否属于 NFC 三类事件之一再取 Tagconstactionintent.getAction()constisNfcIntentactionNfcAdapter.ACTION_TAG_DISCOVERED||actionNfcAdapter.ACTION_TECH_DISCOVERED||actionNfcAdapter.ACTION_NDEF_DISCOVEREDif(!isNfcIntent)returnnullconsttagintent.getParcelableExtra(NfcAdapter.EXTRA_TAG)constbytestag.getId()tag.getId()返回的是卡片出厂 UID 的字节数组不是现成字符串需要逐字节转成十六进制。完整转换过程letuidfor(letindex0;indexbytes.length;index){uid(Number(bytes[index])0xff).toString(16).padStart(2,0)}returnuid.toUpperCase()每一步的原理Number(bytes[index])Java 的 byte 是有符号的范围 -128 到 127先转成数字。 0xff把负数按无符号还原。例如字节值 -1二进制补码 11111111与 0xff 做按位与得到 255对应十六进制ff。如果不做这一步负数会被转成-1这样的错误字符串。toString(16)把 0 到 255 的整数转成十六进制。padStart(2, 0)小于 16 的值只有一位如 5 转成5补前导零成05保证每个字节固定占两位。拼接后toUpperCase()得到统一大写的卡号如5317C716430001与后端白名单口径一致。5.4 前台调度与防重复前台碰卡需要主动注册enableForegroundDispatch让系统优先把 NFC 事件交给当前页面letflagsPendingIntent.FLAG_UPDATE_CURRENTif(Build.VERSION.SDK_INT31)flags|PendingIntent.FLAG_MUTABLEpendingIntentPendingIntent.getActivity(activity,0,intent,flags)adapter.enableForegroundDispatch(activity,pendingIntent,null,null)plus.globalEvent.addEventListener(newintent,handleNewIntent)注意 Android 12API 31及以上强制要求 PendingIntent 显式声明可变性必须加上FLAG_MUTABLE否则在新系统上会直接抛异常。另外手机贴卡时往往会连续触发多次 intent做了两层防护1 秒内相同 UID 的扫描结果去重防抖。提交打卡时加 30 秒超时的全局锁防止同一次碰卡产生重复打卡。读完一次后用一个空 Intent 调setIntent清掉当前 intent避免同一张卡被重复解析。六、难点二钉钉第三方登录的三次方案迭代这是整个项目迭代次数最多的部分前后试了三种方案。6.1 方案一plus.share 内置 SDK —— 不可行最初以为 HBuilderX 内置的 OAuth/Share 模块能直接调钉钉。在 manifest 的「安卓模块配置」里实际确认OAuth 模块只有一键登录、苹果、微信、QQ、微博、Google、Facebook。Share 模块只有微信、QQ、微博。两处都没有钉钉。HBuilderX 官方内置能力不支持钉钉授权这条路走不通必须引入插件。6.2 方案二付费原生插件 WHY-Login —— 放弃DCloud 插件市场的 WHY-Login 是付费原生插件云打包授权 35 元用uni.requireNativePlugin调用。关键限制在于付费原生插件只能在自定义调试基座里试用。试用基座无法脱离 HBuilder 独立安装每次启动还会弹测试提示。正式发布必须购买授权。评估后放弃该方案并用 git 精准回退了这一版的全部改动。6.3 方案三免费 UTS 插件 open-dinglogin —— 最终方案DCloud 插件市场 id28077 的 open-dinglogin 是免费开源的 UTS 插件以uni_modules目录形式存在导入后 HBuilderX 自动识别没有购买环节。调用方式import{authLogin}from/uni_modules/open-dingloginconstauthawaitauthLogin({appId:DINGTALK_APP_KEY,redirectUrl:DINGTALK_REDIRECT_URL,responseType:code,scope:openid,prompt:consent,state:Date.now()})它的工作原理插件内部包含一个原生回调 ActivityDDAuthActivity点击登录后唤起钉钉用户授权完成时由这个原生 Activity 拦截结果再通过 Promise 直接把 authCode 返回给 JS。因此不需要再做一个网页中转页去接回调redirectUrl只需要在钉钉后台登记、与参数一致即可不要求该地址真实可访问。6.4 为什么 UTS 插件必须制作自定义基座标准基座是 DCloud 预编译好的通用运行环境里面不包含任何第三方插件的原生代码。UTS 插件在安卓端带有 Kotlin 原生代码必须重新编译一个包含这些代码的运行环境也就是「自定义调试基座」真机调试时选择这个基座才能唤起钉钉。需要区分清楚调试阶段制作自定义基座在基座里运行。正式发布直接云打包uni_modules里的插件会自动编译进 APK不需要额外勾选或付费。七、难点三authCode 到企业 userid 的完整链路钉钉授权拿到的 authCode 只是一个临时授权码后端需要三步才能换成系统可识别的企业 userid。第一步authCode 换用户访问令牌POST https://api.dingtalk.com/v1.0/oauth2/userAccessToken body: { clientId, clientSecret, code, grantType: authorization_code } 返回: { accessToken, refreshToken, expiresIn }第二步用户令牌换 unionIdGET https://api.dingtalk.com/v1.0/contact/users/me header: x-acs-dingtalk-access-token: 用户访问令牌 返回: { nick, openId, unionId, mobile, email }unionId 是用户在当前应用下的唯一标识。第三步unionId 换企业 useridPOST https://oapi.dingtalk.com/topapi/user/getbyunionid?access_token企业访问令牌 body: { unionid } 返回: { result: { userid } }这一步用的是企业内部应用的访问令牌拿到的 userid 才是钉钉考勤、通讯录接口统一使用的标识。最后用这个 userid 匹配本地user.dingtalk_userid字段匹配成功后签发与账号密码登录完全一致的 JWT前端无需区分登录方式。八、难点四钉钉权限点真机第一次跑通时前两步都成功第三步报错getbyunionid failed (88) subcode60011 应用尚未开通所需的权限: [qyapi_get_member]钉钉应用默认只开了身份验证和消息通知权限通讯录类接口需要手动添加对应权限点。项目里涉及的几个权限点权限点作用qyapi_get_member成员详情读取getbyunionid 换 userid 需要qyapi_get_member_by_mobile按手机号查询单个用户 useridqyapi_get_department_member读取通讯录全体成员qyapi_get_department_list读取部门列表开通路径钉钉开放平台 → 应用详情 → 权限管理 → 搜索权限点 code → 立即开通。开通后无需改代码、无需重新打包直接重试登录即可。如果开通前后端启动时的通讯录同步已经失败重启一次后端重新同步即可。九、难点五签名与证书钉钉后台的安卓配置需要填包名和签名这两个值必须和实际打包证书严格一致。包名uni.app.UNI35FA817。签名填证书的 MD5 指纹通过 keytool 从打包证书导出。关键约束以后每次更新打包都必须选择同一个证书签名指纹才不会变钉钉后台也不用动。一旦更换证书会产生两个后果钉钉后台的安卓签名要重新填写新证书的 MD5。每次更新还要把versionCode递增否则安卓系统不允许同版本号覆盖安装。自有证书文件和密码务必备份证书丢失后无法再给该应用发布升级包。十、后端设计要点10.1 纯标准库不引入 Web 框架后端 HTTP 部分只用 Go 标准库net/http配合database/sql通过http.HandleFunc注册路由依赖少、编译产物单一、部署简单。10.2 异步队列 worker 解耦钉钉同步本地打卡成功后不直接调钉钉而是写入dingtalk_upload队列表后台 worker 每 5 秒轮询处理外部接口故障不会影响打卡主流程。失败任务按指数退避独立重试成功后回写钉钉 request_id。10.3 数据库迁移与接口规范用migrations/目录按编号管理增量 SQL老库按顺序补跑不破坏现有数据。通过go:embed把 OpenAPI 规范内嵌进二进制启动后访问/openapi.json即可拿到完整接口文档。单元测试覆盖登录、注册、签到和钉钉客户端钉钉部分用 mock 上传器不依赖真实环境。十一、部署测试环境使用 Docker Compose 一键拉起 MySQL 和后端首次启动自动执行建表脚本。凭证通过环境变量或本地dingtalk.local.env注入该文件被 gitignore 忽略不进入版本库。前端 HBuilderX 云打包生成 APK后端编译为单个可执行文件整体部署链路较轻。十二、收获总结NFC 读卡要同时覆盖「碰卡启动」和「前台碰卡」两个场景卡片 UID 是字节数组需要按「有符号 byte 还原、转十六进制、补前导零、统一大写」的流程解析前台调度在 Android 12 以上还要处理 PendingIntent 可变性标志。uni-app 接钉钉登录没有内置能力付费插件和免费 UTS 插件的核心差别在于授权成本最终用免费开源的 open-dinglogin 解决带原生代码的 UTS 插件调试必须制作自定义基座正式云打包则自动包含。钉钉服务端链路是 authCode → 用户访问令牌 → unionId → 企业 userid 三步每一步对应不同接口和字段命名规则通讯录类接口要单独开通权限点否则会在最后一步被拒绝。签名证书是安卓应用的长期身份包名、签名、证书三者必须保持一致更新时 versionCode 递增、证书不变才能稳定覆盖安装并保留钉钉登录能力。后端用异步队列隔离外部依赖、用 state 路由多组织凭证、用编号迁移和内嵌 OpenAPI 管理演进这些设计让系统在对接钉钉这类外部平台时更可控、更容易排查问题。
阅读完成 · 觉得有帮助?
咨询建站