ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

Vue项目接入华视身份证读卡器:从浏览器安全到WebSocket中间服务的完整实践

Vue项目接入华视身份证读卡器:从浏览器安全到WebSocket中间服务的完整实践 接这个需求之前我一度以为前端调身份证读卡器是个很简单的活儿——无非就是浏览器调一个读卡接口拿到身份证号、姓名、照片然后传给后端。真正动手之后才发现这里面的坑一点不比业务代码少。尤其是把华视读卡器接进Vue项目整个过程涉及浏览器安全限制、本地通信、数据解析、设备状态管理任何一环没想清楚到了现场就是一顿手忙脚乱。这篇文章我尽量把整个接入过程讲透从方案选型到Vue端实现再到周围同事最容易翻车的几个点。如果你正好被分配了在Vue项目里接身份证读卡器这个任务或者后续要给公司做访客登记、实名认证、柜台业务系统这篇应该能让你少走一大截弯路。1. 华视读卡器接入Web系统的两种主流方案先看清技术边界1.1 为什么浏览器不能直接插上就读先说清楚一个问题为什么身份证读卡器不能像U盘一样插到电脑上、浏览器里调个API就出数据核心原因是浏览器安全沙箱。现代浏览器出于安全考虑不允许网页直接访问电脑上的本地硬件设备。WebUSB、Web Serial这些API虽然存在但一方面兼容性参差不齐另一方面身份证读卡器的驱动和通信协议根本不会通过浏览器标准接口暴露出来。而且身份证读卡器读取的是敏感个人信息浏览器层面更不可能放一个通用接口让任何网站随便调。华视读卡器常见的有CVR-100U、CVR-100B等型号通过USB连接电脑后厂商提供的是一套SDK通常是C#、Java、C或者Delphi封装的动态库。这套SDK负责与硬件通信、驱动读卡、解析身份证信息。问题来了——浏览器里的JavaScript不可能直接加载并调用这个动态库连都不行因为压根不是一个运行环境。所以现实的做法是加一层中间人。1.2 方案一ActiveX或浏览器插件方案早年很多政务系统、酒店管理系统是这么干的在IE浏览器里装ActiveX控件页面通过new ActiveXObject(CVR.IDCard)之类的代码直接调用读卡器SDK。这个方案的好处是开发简单浏览器端直接调用不用额外起服务。但放在现在基本等于给自己埋雷只支持IE浏览器或者Edge的IE兼容模式Chrome、Firefox想都别想每次换电脑、换浏览器都要重新装控件、调安全设置64位系统下ActiveX的兼容问题能折腾死人新项目如果再上ActiveX前端同事大概率会直接崩溃。除非你们的项目被硬性要求跑在老旧浏览器上否则我不推荐走这条路。1.3 方案二本地WebSocket中间服务推荐这个方案的做法是在安装了读卡器驱动的电脑上额外运行一个本地小服务用C#、Java、Python、Node.js都行。这个服务调用华视SDK读写卡器通信同时对外暴露一个WebSocket接口。浏览器里的Vue页面通过ws://127.0.0.1:端口连接这个服务发送指令、接收身份证数据。好处非常明显浏览器无关Chrome、Edge、Firefox都能用前端只面对WebSocket逻辑简单不需要处理各种插件兼容问题C#或Java的SDK封装比前端直接调硬件可靠得多出问题也好排查本地服务可以做成开机自启动对使用人员来说感知很弱。我当时做的项目最终就选了WebSocket方案。下面所有实现细节都是基于这个方案来讲。两种方案的对比整理成了一张表方便你判断自己项目该怎么选对比维度ActiveX/插件方案WebSocket中间服务方案浏览器兼容性仅IE或兼容模式Chrome、Edge等主流浏览器开发成本前端代码少环境配置多多一个本地服务前端代码略多部署维护每台客户端要装控件、调IE设置客户端运行一个服务可开机自启稳定性受浏览器版本、系统位数影响大服务独立运行稳定可控适用场景老系统改造受限新系统、跨浏览器推荐2. 本地中间服务部署华视SDK的封装与调通2.1 中间服务到底干了什么活在写Vue代码之前得先确保本地中间服务是通的否则后面全是白忙。我给这个中间服务起了个名字叫IdCardLocalService它的职责非常纯粹启动时加载华视SDK动态库初始化读卡器设备开启WebSocket服务监听本机某个端口收到前端的读卡指令后调用SDK的读卡接口把身份证信息读出来把读到的文本信息和照片转成JSON格式通过WebSocket回传给前端。这里有一个容易忽略的细节中间服务要区分指令和事件。比如前端主动下发readCard指令服务端返回一次读卡结果这是指令-响应。而读卡器设备插入/拔出、读卡过程中的错误提示属于事件需要服务端主动推送给前端。我当时在设计协议时把消息分成了type字段值为response代表响应值为event代表主动推送这样前端处理起来就很清晰。2.2 端口和通信协议约定端口号这个事看起来不起眼但真的会影响日常使用。选端口时注意别跟常见软件冲突我当时为了避免和开发环境常用的8080、3000、8888这些端口撞车指定了一个不那么常见的端口比如56789。前端连的地址就是ws://127.0.0.1:56789注意不能用localhost建议统一用127.0.0.1因为某些环境下localhost会解析成IPv6的::1而服务只监听了IPv4导致连接失败。消息格式我们约定为JSON结构大致是这样// 前端 - 服务端 { cmd: readCard }// 服务端 - 前端读卡成功 { type: response, cmd: readCard, success: true, data: { name: 张三, gender: 男, nation: 01, birthday: 19900101, address: 北京市朝阳区xxx, idNumber: 110101199001010011, issueAuthority: 北京市公安局朝阳分局, validPeriodStart: 20200101, validPeriodEnd: 20400101, photoBase64: /9j/4AAQSkZJRgABAQEAAAAAAAD... } }// 服务端 - 前端读卡失败/无卡 { type: response, cmd: readCard, success: false, message: 请放置身份证 }读卡器这种设备有个特点读证不是瞬间完成的。用户把身份证放上去到你拿到数据中间可能有几百毫秒到一两秒的延迟。所以前端发指令后不能立刻放弃等待后续代码里要注意超时时间的设置我一般设3秒超过3秒还没读到就提示用户重新放置。2.3 联调验证先别写Vue用工具测通了再说很多同事上来就先写前端代码然后发现调试时根本分不清是前端问题、服务问题还是硬件问题。我的习惯是先用一个最简单的WebSocket客户端把中间服务调通了再动Vue代码。可以用的工具Postman新版支持WebSocket请求可以建立连接、发送消息、看返回JetBrains IDE自带的WebSocket客户端在线WebSocket测试网站注意别把身份证数据发到未知网站建议离线环境用本地工具。调通之后整理一个检查清单[ ] 中间服务启动后端口处于监听状态[ ] WebSocket客户端能连上[ ] 发送readCard指令无卡时返回失败消息[ ] 放上身份证能返回完整JSON且照片字段是Base64[ ] 拿开身份证再放另一张数据能正常刷新[ ] 拔掉读卡器USB再插上服务能自动恢复读卡能力。这六项全过硬件和中间服务的问题基本排除剩下的活就都在前端了。3. Vue前端完整实现从连接管理到身份证信息展示3.1 封装一个useIdCardReader的Composable中间服务通了回到Vue这边。我的做法是把所有跟读卡器相关的逻辑抽到一个独立的模块里而不是散落在各个页面组件中。如果你用的Vue 3可以直接封装成一个Composable如果你还在用Vue 2那就封装成一个公共的JS工具类。核心逻辑是一样的。这个模块要管的事情有WebSocket连接的建立、关闭、异常重连发送读卡指令并监听返回把返回的身份证数据解析成前端友好的结构暴露给业务组件的状态是否连接、是否在读卡、当前读到的身份证信息、错误信息。连接管理这部分有一个很关键的体验问题WebSocket的readyState只有CONNECTING、OPEN、CLOSING、CLOSED四种状态没有正在读卡这种业务状态。所以我把业务状态拆成了两层一层是wsConnected表示与本地服务的连接是否正常另一层是reading表示当前是否正在等待读卡结果。这两层状态混在一起页面上的提示就说不清楚。下面是一段简化版的核心代码// src/composables/useIdCardReader.js import { ref, onBeforeUnmount } from vue const WS_URL ws://127.0.0.1:56789 export function useIdCardReader() { const wsConnected ref(false) // WebSocket连接状态 const reading ref(false) // 是否正在读卡 const cardInfo ref(null) // 读到的身份证信息 const errorMsg ref() // 错误信息 let ws null let connectRetryTimer null let readTimeoutTimer null function connect() { if (ws (ws.readyState WebSocket.OPEN || ws.readyState WebSocket.CONNECTING)) { return } ws new WebSocket(WS_URL) ws.onopen () { wsConnected.value true errorMsg.value } ws.onclose () { wsConnected.value false reading.value false // 断线自动重连这里加个延时避免频繁重连 connectRetryTimer setTimeout(connect, 2000) } ws.onerror () { errorMsg.value 与读卡服务连接异常请确认本地服务已启动 wsConnected.value false } ws.onmessage (event) { const msg JSON.parse(event.data) if (msg.type response msg.cmd readCard) { if (readTimeoutTimer) { clearTimeout(readTimeoutTimer) readTimeoutTimer null } reading.value false if (msg.success) { cardInfo.value parseCardData(msg.data) errorMsg.value } else { cardInfo.value null errorMsg.value msg.message || 读卡失败请重新放置身份证 } } } } function readCard() { if (!ws || ws.readyState ! WebSocket.OPEN) { errorMsg.value 读卡服务未连接 return } // 清掉上一次的结果避免界面还显示着旧数据 cardInfo.value null errorMsg.value reading.value true ws.send(JSON.stringify({ cmd: readCard })) // 3秒超时超过就重置状态让用户重新放置身份证 readTimeoutTimer setTimeout(() { reading.value false errorMsg.value 读卡超时请重新放置身份证 }, 3000) } function disconnect() { if (connectRetryTimer) { clearTimeout(connectRetryTimer) connectRetryTimer null } if (readTimeoutTimer) { clearTimeout(readTimeoutTimer) readTimeoutTimer null } if (ws) { ws.close() ws null } } onBeforeUnmount(() { disconnect() }) return { wsConnected, reading, cardInfo, errorMsg, connect, readCard, disconnect } }3.2 身份证字段解析与格式化华视SDK返回的字段一般是简写或固定格式前端直接展示会很难看。比如民族返回的是01这种代码出生日期是19900101这种字符串有效期限可能是一个很长的时间段。所以解析这一步要把它们格式化成人能看懂的内容。身份证包含的常见字段和映射关系如下SDK返回字段含义前端处理name姓名直接展示gender性别直接展示nation民族代码需映射为中文民族名称birthday出生日期19900101-1990年01月01日address住址直接展示idNumber公民身份号码校验并脱敏展示issueAuthority签发机关直接展示validPeriodStart有效期限起始20200101-2020.01.01validPeriodEnd有效期限结束可能为长期或具体日期photoBase64证件照Base64直接作为img的src民族代码这块如果中间服务里没有做映射前端就得自己维护一张表。汉族的代码一般是01其他的按GB/T 3304标准排。我贴一段处理民族和日期格式的代码// src/utils/idCard.js const NATION_MAP { 01: 汉族, 02: 蒙古族, 03: 回族, 04: 藏族, 05: 维吾尔族, 06: 苗族, 07: 彝族, 08: 壮族, 09: 布依族, 10: 朝鲜族, 11: 满族, 12: 侗族, // ... 还有几十个不一一列了实际开发时把全表维护好 } function formatDate(dateStr) { if (!dateStr || dateStr.length ! 8) return dateStr || const year dateStr.slice(0, 4) const month dateStr.slice(4, 6) const day dateStr.slice(6, 8) return ${year}-${month}-${day} } function formatValidPeriod(endStr) { if (!endStr) return // 长期证件有效期结束可能是 长期 或 99991231 之类 if (endStr 长期 || endStr 99991231) return 长期 return formatDate(endStr) } export function parseCardData(data) { return { name: data.name || , gender: data.gender || , nation: NATION_MAP[data.nation] || data.nation || , birthday: formatDate(data.birthday), address: data.address || , idNumber: data.idNumber || , issueAuthority: data.issueAuthority || , validPeriodStart: formatDate(data.validPeriodStart), validPeriodEnd: formatValidPeriod(data.validPeriodEnd), photoBase64: data.photoBase64 || } }有个细节想提醒一下身份证号码在界面上展示时如果是给业务人员看的建议中间几位打星号。比如110101********0011。这样既能核对身份又避免完整号码暴露在屏幕上被旁边的人看到。但传给后端做实名认证时肯定是完整号码这个是后端接口内部传输的事。3.3 页面交互与防重复提交UI层面的交互主要围绕读卡过程的状态反馈来做。用户把身份证放到读卡器上到识别完成大概有那么一两秒如果界面上没有任何反馈用户会反复挪动身份证反而降低成功率。我一般会在页面里放一个读卡区域分几种状态提示未连接服务红字提示读卡服务未连接请先启动本地服务已连接等待放卡显示请放置身份证和一个小动画动画可以用CSS做一个呼吸灯效果不需要引第三方库已放卡正在读取显示正在读取...并禁用页面的提交按钮读取成功显示身份证信息预览卡片确认后提交表单读取失败显示具体错误信息按钮恢复可用。防重复提交是实名场景里特别重要的一环。用户把身份证放在读卡器上前端会收到一次结果但只要卡片没拿走部分中间服务可能会重复推送数据或者用户手抖又点了一次读卡按钮结果被提交了两次。解决方式有两种我建议两层都加第一层是前端状态锁。在等待结果期间reading是true提交按钮禁用等成功或失败后再恢复。第二层是结果幂等。比对身份证号码如果跟当前表单里已经填充的身份证号相同直接忽略重复结果。另外还有一个很实际的问题用户实际使用的浏览器可能是最大化窗口读卡提示区域如果放在页面底部用户根本看不到。我建议把读卡提示做成一个固定在页面顶部或侧边的状态条或者使用Toast组件。毕竟读卡是个硬件操作提示能不能被看到直接影响业务办理效率。4. 实名场景的坑重复读证、状态残留与敏感数据保护4.1 上一次的结果为什么会残留我第一版做出来的时候测试同事提了一个bug给A办完业务A的身份证还在读卡器上给B办业务时页面自动弹出了A的身份信息。排查后发现问题出在状态残留。原因很简单很多读卡器中间服务在没有读到新卡时会返回上一次读到的数据或者根本没有做状态清理。前端如果盲目信任每次返回就会把A的信息当成新结果上报。解决方案上面代码里其实提到了每次发起读卡指令前先把cardInfo清空并且把提交按钮置灰。同时在后端接口上也要加一道防线——提交业务数据时带上一个readSeq或时间戳用来确保是本次读卡操作产生的结果而不是历史残留。如果你发现中间服务总是返回旧数据还可以在服务端把无卡时返回旧数据这个行为改掉改成无卡时返回success: false。这一般在中间服务里加一个是否检测到卡的判断就能解决。4.2 多个页面共享读卡结果实名业务往往不是单一页面搞定的。比如访客登记先在第一步读身份证第二步要带出身份证信息填表单第三步可能要关联到其他系统。如果cardInfo只存在于某个页面的组件里路由一切换数据就没了。我的建议是用PiniaVue 3或VuexVue 2把读卡器状态和读卡结果放进全局store。这样无论哪个页面、哪个弹窗需要读卡拿的都是同一份最新结果。同时掉线重连的逻辑也可以通过store里的wsConnected状态驱动全局提示不用每个页面单独监听。需要注意全局store里的身份证信息属于敏感数据不要在console里打印也不要塞进URL参数里。页面组件销毁时如果业务已经完成了可以考虑把store里的cardInfo重置避免数据长期驻留在内存中。4.3 敏感信息的展示与日志脱敏身份证读卡涉及公民个人信息这不是一句注意安全就完事的。我在项目里做了几件比较实际的事界面脱敏。身份证号码、住址这些字段在界面上做局部脱敏后再展示。刚才说的身份证号打星可以做到住址这一栏如果业务上不强制要求看到完整地址也可以只保留前几个字比如北京市朝阳区********。接口防泄露。前端不会在URL里拼身份证号参数统一走POST请求body用JSON。后端日志里打印请求参数的时候要注意脱敏配置别把身份证号、姓名原样打进日志文件。照片的处理。身份证照片读出来是Base64这个字符串非常大少说几十KB直接放在Vue的响应式数据里会导致页面卡顿。建议在拿到Base64后只赋值给图片的src不要让这个超长字符串进入表格、表单提交之类的地方。如果后端需要照片文件前端应该把Base64转成Blob再上传不要直接在JSON里传一大坨Base64。转Blob的代码也给一下// Base64转Blob function base64ToBlob(base64, mimeType) { const byteCharacters atob(base64) const byteArrays [] for (let offset 0; offset byteCharacters.length; offset 512) { const slice byteCharacters.slice(offset, offset 512) const byteNumbers new Array(slice.length) for (let i 0; i slice.length; i) { byteNumbers[i] slice.charCodeAt(i) } byteArrays.push(new Uint8Array(byteNumbers)) } return new Blob(byteArrays, { type: mimeType }) }4.4 读卡成功的提示音怎么处理华视读卡器在成功读取身份证时通常会有蜂鸣声这是硬件自带的不需要额外处理。但有些用户的电脑没插音响或者读卡器蜂鸣声很小容易被忽略。一个常规的做法是前端在收到读卡成功消息后用Web Audio API生成一个短促的提示音。代码非常简单function playSuccessTone() { const audioCtx new (window.AudioContext || window.webkitAudioContext)() const oscillator audioCtx.createOscillator() const gainNode audioCtx.createGain() oscillator.connect(gainNode) gainNode.connect(audioCtx.destination) oscillator.frequency.value 880 oscillator.type sine gainNode.gain.setValueAtTime(0.3, audioCtx.currentTime) gainNode.gain.exponentialRampToValueAtTime(0.01, audioCtx.currentTime 0.3) oscillator.start(audioCtx.currentTime) oscillator.stop(audioCtx.currentTime 0.3) }注意浏览器的自动播放策略要求在用户交互后音频上下文才允许播放所以这个功能跟用户点了一次读卡按钮配合是没问题的但如果页面加载后什么都不点直接放卡读卡提示音可能因为浏览器的自动播放限制而无法播放。这个属于浏览器层面的限制理解原因就行。5. 完整排查链路从没反应到稳定可用最后这部分是排错经验说几个我实际遇到过而且有代表性的问题。每个问题我都会按现象 - 排查过程 - 根因 - 解决方式来写这样你遇到类似问题时可以照着排查。5.1 现象一WebSocket连接成功但发送readCard指令后没有响应这是最典型的中间服务配置问题。排查过程先用Postman连WebSocket发readCard指令发现确实没有响应。于是看中间服务的日志发现它收到了指令但阻塞在SDK的读卡调用上。再看读卡器状态原来中间服务启动时读卡器没插好SDK初始化的设备句柄是无效的。根因中间服务启动顺序和读卡器插拔顺序不一致。服务启动时如果没检测到读卡器有些SDK并不会报错只是设备句柄无效等到指令下发时就卡住了。解决方式改中间服务代码启动时如果发现设备未就绪直接返回错误不要让服务处于假正常状态。同时增加设备插拔的事件监听在设备重新插入后自动重新初始化SDK。前端在页面上显示读卡服务异常请检查读卡器连接并重启本地服务让操作人员不用猜。5.2 现象二第一次读卡正常第二次开始完全没反应排查过程第一次能读到说明SDK初始化、通信链路都是通的。第二次没反应我从三个角度排查前端WebSocket连接是否断了中间服务是否在处理完第一次请求后崩溃了SDK是不是没有正确释放资源根因多数情况下是中间服务处理完第一次读卡后没有正确释放读卡器资源SDK内部状态卡死了。这种情况在华视SDK的某些版本上特别容易出现尤其是一次读卡后立即拔掉身份证、插入下一张时。解决方式在中间服务的读卡逻辑里每次读卡完成后主动调用SDK的复位或释放接口不要懒省事。如果SDK没有提供明确的复位接口就把读卡器设备在服务内部重连一次。前端侧的规避策略是每次读卡成功后主动把reading状态恢复并且给用户一个请拿走身份证的提示让卡片在位状态有时间复位。这里有个细节要注意不要试图用前端反复发指令来撞开这个卡死的状态前端发一百次也解决不了SDK内部的问题。正确的做法是服务端做异常捕获如果SDK读卡超时或返回异常自动走设备重连逻辑。5.3 现象三本地开发环境一切正常部署到客户电脑上连不上排查过程客户电脑上打开系统页面提示读卡服务未连接。我先检查浏览器是否正常再检查客户电脑上是否安装了读卡器驱动和中间服务。发现服务确实装了但在任务管理器里看不到进程一查发现服务程序被杀毒软件拦截了。根因本地中间服务这类程序经常被安全软件当成可疑进程处理尤其是没有数字签名的自制小服务。再加上它监听了本地端口更容易被安全软件重点关注。解决方式给中间服务做代码签名虽然不是强制的但能明显降低被杀毒软件拦截的概率。另外部署文档里要写清楚安装后需要把服务进程加入杀毒软件白名单或者至少在防火墙里放行对应端口。不要觉得这是小事实名制场景的客户现场电脑安全策略通常很严格这个问题早晚会碰到。5.4 现象四浏览器提示无法连接到WebSocket但服务明明在跑排查过程客户电脑上服务是启动的端口也监听着但浏览器报错。我远程看了下发现客户用Chrome访问系统时地址栏是https://xxx.com而我的WebSocket地址是ws://127.0.0.1:56789这属于HTTPS页面里混入了不安全的WebSocket连接。根因从HTTPS页面发起ws://连接浏览器会认为这是混合内容直接拦截。Chrome对这种情况尤其严格。解决方式有两个层面的处理。第一层让中间服务支持wss://其实就是给它配一张自签名证书前端连接时忽略证书校验。第二层如果只是在局域网内部使用把系统页面改成HTTP访问就能直接用ws://。但实名系统一般都有等保要求HTTPS几乎跑不掉所以更稳妥的做法是第一个方案让本地服务支持wss。自签名证书的方案会导致浏览器首次访问时出现不安全提示通常在部署文档里引导客户手动信任一次证书就能解决。这块逻辑不复杂但很绕建议你提前在测试环境把方案验证好别到了客户现场再折腾。5.5 部署前一定要过一遍的验收清单整理项目验收前的自测清单按顺序过一遍大部分坑都能提前排掉检查项预期结果状态中间服务未启动时页面提示是否清晰页面显示读卡服务未连接首次启动中间服务读卡器未插好页面或服务日志给出明确错误提示读卡器USB插拔一次后服务是否自动恢复无需重启服务即可重新读卡连续读不同身份证20次无死锁或漏读每次都能正确读到新信息读卡成功后快速点击提交按钮不会产生重复提交身份证号码在页面和后端日志中的展示已脱敏HTTPS域名下读卡功能是否正常页面能正常连接中间服务电脑重启后中间服务自动启动免人工干预读卡过程中网络断开再恢复页面自动重连无需刷新写在最后的个人体会身份证读卡器接入Vue项目技术上并不复杂真正的复杂度在于它把硬件设备本地服务浏览器安全策略敏感数据合规几个完全不同的领域凑到了一起。任何一个环节出问题表现出的症状都差不多——页面没反应但原因可能千差万别。我最大的体会是这种带硬件的功能测试环境再顺到了客户现场都可能出现奇怪的问题。所以在设计系统时把错误提示写清楚、把日志记录完整、把部署文档写到位比多写几百行业务代码更有价值。前端能做的是尽量让使用者清楚地知道现在到底卡在哪了而不是抛出一个干巴巴的读卡失败请联系管理员。如果你正准备在自己的项目里接入华视身份证读卡器建议从中间服务方案入手先把硬件和服务调通再写Vue代码。前端部分的Composable封装、状态管理、脱敏处理这些都是可以复用的经验希望这篇文章能帮你少踩几个坑。
返回列表