ARTICLE DETAIL

资讯详情

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

SpringBoot+Vue一卡通消费系统:刷卡刷码刷脸三合一实战

SpringBoot+Vue一卡通消费系统:刷卡刷码刷脸三合一实战 简介这是一套基于SpringBootVue前后端分离架构的一卡通消费系统完整源码面向计算机相关专业的毕设、课设学生以及需要实战练手的Java开发者可解决校园卡消费场景中刷卡、扫码、人脸识别等多种支付方式的系统实现问题。压缩包共748个文件约1.67MB以380个Java后端源码、92个Vue前端组件、82个JavaScript脚本、55个XML配置及83个SVG图标资源为主另含SCSS样式、YML与properties配置、bat启动脚本等前后端模块划分清晰。资源中的源码均经过本地编译验证下载后按文档配置环境即可运行项目难度适中内容经助教老师审定。目前已有90人学习下载适合作为毕业设计参考或课程作业模板帮助读者快速理解一卡通消费系统的业务逻辑、接口设计与前后端联调思路节省从零搭建的时间成本。1. 一卡通消费系统为什么值得用 SpringBoot Vue 重做一遍校园、园区、企业食堂里那套「一张卡走天下」的消费系统正在被重新定义。过去实体卡是唯一入口丢卡、补卡、代刷、余额对不上是日常现在同一套系统要同时接住实体卡、付款码和人脸三种介质后台还要把账户、终端、订单、对账串成一条线。这个标题讲的就是一套基于 SpringBoot Vue 前后端分离架构的一卡通消费系统把刷卡、刷码、刷脸三种支付方式收敛到同一个账户体系里。它适合谁一是手里有存量一卡通项目、想升级多介质支付的开发者二是要做食堂、门禁、考勤一体化方案的团队三是想拿一个真实业务练前后端分离项目实战的人。难点不在「能不能跑」而在三件事三种介质怎么映射到同一个用户、扣款怎么保证不重复不超扣、离线终端断网了账怎么补。下面按我实际落地的顺序拆开讲。2. 三种支付介质怎么统一到一个账户模型2.1 先想清楚「介质」和「账户」是两层很多人一上来就建三张表卡表、码表、人脸表然后每张表都挂余额。这是翻车的起点。正确做法是把「支付凭证」和「资金账户」彻底分开账户只有一个余额、状态、冻结金额都在账户上实体卡、付款码、人脸只是三种指向账户的凭证。这样对账时只需要盯账户流水不用在三张表之间做加法。常见做法是设计四张核心表account账户、credential凭证用type区分 CARD/QR/FACE、trade_order交易订单、terminal消费终端。凭证表里存credential_no卡号/码值/人脸特征ID和account_id一个账户可以挂多张凭证。人脸不直接存图片存的是特征向量或第三方返回的face_id这点后面避坑章会展开。2.2 建表与关键字段-- 账户表资金唯一归属 CREATE TABLE account ( id BIGINT PRIMARY KEY AUTO_INCREMENT, user_id BIGINT NOT NULL COMMENT 关联用户, balance DECIMAL(12,2) NOT NULL DEFAULT 0 COMMENT 余额单位元, frozen DECIMAL(12,2) NOT NULL DEFAULT 0 COMMENT 冻结金额, status TINYINT NOT NULL DEFAULT 1 COMMENT 1正常 0冻结, version INT NOT NULL DEFAULT 0 COMMENT 乐观锁版本, UNIQUE KEY uk_user (user_id) ) COMMENT 资金账户; -- 凭证表卡/码/人脸统一入口 CREATE TABLE credential ( id BIGINT PRIMARY KEY AUTO_INCREMENT, account_id BIGINT NOT NULL, type VARCHAR(8) NOT NULL COMMENT CARD/QR/FACE, credential_no VARCHAR(128) NOT NULL COMMENT 卡号/码token/人脸特征ID, status TINYINT NOT NULL DEFAULT 1, UNIQUE KEY uk_type_no (type, credential_no), KEY idx_account (account_id) ) COMMENT 支付凭证; -- 交易订单幂等核心 CREATE TABLE trade_order ( id BIGINT PRIMARY KEY AUTO_INCREMENT, order_no VARCHAR(32) NOT NULL COMMENT 业务订单号, account_id BIGINT NOT NULL, terminal_id BIGINT NOT NULL, amount DECIMAL(12,2) NOT NULL, pay_type VARCHAR(8) NOT NULL, status TINYINT NOT NULL DEFAULT 0 COMMENT 0待扣 1成功 2失败 3已冲正, request_id VARCHAR(64) NOT NULL COMMENT 终端请求唯一号幂等键, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_request (request_id), UNIQUE KEY uk_order (order_no) ) COMMENT 交易订单;逻辑说明account.version是乐观锁字段扣款时用UPDATE ... SET balancebalance-?, versionversion1 WHERE id? AND version?保证并发安全。trade_order.request_id是终端生成的请求唯一号配合唯一索引做幂等——同一笔请求重复提交只会落一条订单。参数上balance用DECIMAL(12,2)而不是FLOAT金额绝不能用浮点这是血泪经验。2.3 扣款主流程Transactional(rollbackFor Exception.class) public PayResult pay(PayCommand cmd) { // 1. 幂等先查 request_id 是否已处理 TradeOrder exist orderMapper.selectByRequestId(cmd.getRequestId()); if (exist ! null) { return PayResult.of(exist); // 直接返回历史结果不重复扣 } // 2. 凭证换账户 Credential cred credentialMapper.selectByTypeAndNo(cmd.getType(), cmd.getCredentialNo()); if (cred null || cred.getStatus() ! 1) { throw new BizException(凭证无效); } // 3. 乐观锁扣款 int rows accountMapper.deduct(cred.getAccountId(), cmd.getAmount()); if (rows 0) { throw new BizException(余额不足或并发冲突); } // 4. 落订单 TradeOrder order buildOrder(cmd, cred.getAccountId()); orderMapper.insert(order); return PayResult.of(order); }逻辑说明先查幂等再扣款顺序不能反。deduct的 SQL 里同时判断balance amount和version一条语句完成校验加扣减。参数cmd.getAmount()在进入前要做正数校验和单笔限额校验限额建议放在终端配置里而不是写死。失败时看rows0是余额不足还是版本冲突前者提示用户后者重试一次即可。3. 人脸、刷码、实体卡三条链路怎么落地3.1 实体卡链路读卡号到扣款最短实体卡最简单终端读到物理卡号直接调pay接口typeCARD。要注意的是卡号可能被复制所以卡上最好带扇区密钥或校验位终端侧做一次本地校验再上送。常见做法是卡号加一个终端本地密钥做 HMAC服务端再验一次防止伪造卡号直接调接口。3.2 刷码链路动态码 短时效付款码不能是静态的否则截图就能盗刷。做法是 App 端每隔一段时间比如 30 秒用userId 时间片 密钥生成一个动态 token服务端用同样的算法校验时间窗口。终端扫到码后把 token 上送服务端解出userId再换账户。// 生成动态码App端 public String genQrCode(Long userId) { long slice System.currentTimeMillis() / 30000; // 30秒一片 String raw userId : slice; String sign hmacSha256(raw, SECRET); // 服务端共享密钥 return Base64.encode(raw : sign); } // 校验服务端 public Long verifyQrCode(String code) { String[] parts new String(Base64.decode(code)).split(:); long userId Long.parseLong(parts[0]); long slice Long.parseLong(parts[1]); long now System.currentTimeMillis() / 30000; if (Math.abs(now - slice) 1) { // 允许前后一片防时钟漂移 throw new BizException(码已过期); } String expect hmacSha256(userId : slice, SECRET); if (!expect.equals(parts[2])) { throw new BizException(码校验失败); } return userId; }逻辑说明时间片用 30 秒校验时允许前后各一片是为了容忍终端和手机的时钟偏差这个容差参数别设太大否则等于延长了码的有效期。SECRET必须服务端和 App 端安全共享不能硬编码在能被反编译拿到的地方实际项目里建议走一次密钥协商。3.3 人脸链路特征比对而不是图片比对人脸这块最容易踩的坑是把图片传来传去。正确链路是终端采集人脸 → 本地或边缘侧提取特征 → 上送特征值或face_id→ 服务端在凭证表里查typeFACE的记录做匹配。如果用人脸识别 SDK常见的是返回一个特征向量或人员ID服务端只做「这个特征属于哪个账户」的映射不做原始图片存储。public PayResult payByFace(FacePayCommand cmd) { // cmd.faceFeature 是终端提取的特征值或SDK返回的人员ID Credential cred credentialMapper.selectByTypeAndNo(FACE, cmd.getFaceFeature()); if (cred null) { // 特征未注册走注册引导或拒绝 throw new BizException(人脸未绑定账户); } PayCommand pay new PayCommand(); pay.setType(FACE); pay.setCredentialNo(cmd.getFaceFeature()); pay.setAmount(cmd.getAmount()); pay.setRequestId(cmd.getRequestId()); return pay(pay); // 复用统一扣款 }逻辑说明人脸链路最终也走同一个pay保证三种介质扣款逻辑一致。参数faceFeature的稳定性取决于 SDK注册时和识别时的特征提取必须用同一套模型换模型会导致老特征全部失效这是升级时最容易被忽略的点。4. 前后端分离下 Vue 端和 SpringBoot 怎么对接4.1 接口约定与跨域前后端分离第一件事是把接口契约定死。建议统一响应体{code, msg, data}code0成功。开发阶段 Vue 跑在 5173SpringBoot 跑在 8080跨域用后端配置而不是前端代理硬扛。Configuration public class CorsConfig implements WebMvcConfigurer { Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping(/api/**) .allowedOriginPatterns(*) // 生产环境换成具体域名 .allowedMethods(GET,POST,PUT,DELETE) .allowCredentials(true) .maxAge(3600); } }逻辑说明allowedOriginPatterns在带 cookie 的场景下不能用allowedOrigins(*)否则浏览器会拒绝。生产环境务必把来源收窄到自己的域名allowCredentials(true)和通配来源是互斥的这点很多人配错后一直报跨域。4.2 终端消费页面的关键交互消费终端页面收银台是 Vue 端的核心它要处理三种输入读卡器事件、扫码枪输入、摄像头人脸回调。建议把三种输入统一成一个payRequest对象再提交避免三套逻辑。// 统一支付入口 async function submitPay(payload) { // payload: { type, credentialNo, amount, requestId } const { data } await axios.post(/api/pay, payload); if (data.code 0) { showSuccess(data.data.balance); // 展示扣后余额 } else { showError(data.msg); } } // 读卡器监听键盘模拟输入多数读卡器模拟键盘 let buffer ; window.addEventListener(keydown, e { if (e.key Enter) { submitPay({ type: CARD, credentialNo: buffer, amount: currentAmount, requestId: genId() }); buffer ; } else { buffer e.key; } });逻辑说明很多读卡器是键盘模拟设备会快速「打字」再回车所以用keydown缓冲拼卡号是常见做法。requestId每次提交都要新生成重试时复用同一个才能命中服务端幂等。参数currentAmount来自收银员输入或商品合计提交前要做非空和正数校验。4.3 打包部署Vue 产物放进 SpringBoot生产环境常见做法是把 Vue 打包产物丢进 SpringBoot 的静态资源目录一个 jar 搞定部署。# 前端打包 npm run build # 产物在 dist/ # 拷贝到后端静态目录 cp -r dist/* ../backend/src/main/resources/static/ # 后端打包 mvn clean package -DskipTests java -jar target/card-system.jar逻辑说明Vue 路由要用 history 模式的话后端需要配置 fallback把所有非/api的 404 转发到index.html否则刷新页面会 404。参数上-DskipTests只在确认测试通过后用别养成习惯。5. 上线前必须排查的 5 个坑5.1 现象同一笔消费扣了两次原因终端网络超时后自动重试但服务端没有幂等控制两次请求都落库扣款。解决trade_order.request_id加唯一索引扣款前先查重复请求直接返回历史结果。重试时终端必须复用同一个requestId不能每次新生成。5.2 现象高峰期余额扣成负数原因并发扣款时先查余额再更新两个线程都查到足够余额。解决把校验和扣减合并成一条UPDATE ... WHERE balance ? AND version ?靠数据库行锁和乐观锁兜底应用层不做「先查后改」。5.3 现象人脸识别时好时坏原因注册和识别用了不同版本的 SDK 或不同阈值光照变化导致特征漂移。解决固定一套模型和阈值注册时多角度采集几张取平均特征识别阈值别设太松宁可让用户多刷一次也别误扣到别人账户。5.4 现象对账时订单和账户流水对不上原因扣款成功但订单落库失败或反过来事务边界没包住。解决扣款和落订单必须在同一个Transactional里且订单状态要有中间态对账时用「账户变动流水」为准而不是订单表两者差异要能定位到具体request_id。5.5 现象终端断网后消费数据丢失原因终端只走在线扣款断网直接失败。解决终端本地做离线额度控制断网时先本地记账并冻结额度恢复后批量上送补扣服务端用requestId去重。离线额度要设上限防止终端被刷爆。6. 把对账做成可验证的自动化脚本系统上线后最值钱的不是功能是「账能不能对上」。我一般会写一个对账脚本每天定时跑把账户流水、订单表、终端上送记录三方比对差异落表告警。核心思路是以账户变动流水为基准逐笔核对订单状态和终端请求。# 对账脚本三方比对 import pymysql def reconcile(day): conn pymysql.connect(host127.0.0.1, userapp, password***, databasecard) cur conn.cursor() # 1. 账户流水基准 cur.execute(SELECT request_id, account_id, amount FROM account_flow WHERE DATE(create_time)%s, (day,)) flows {r[0]: r for r in cur.fetchall()} # 2. 订单表 cur.execute(SELECT request_id, status, amount FROM trade_order WHERE DATE(create_time)%s, (day,)) orders {r[0]: r for r in cur.fetchall()} # 3. 比对 diff [] for rid, flow in flows.items(): order orders.get(rid) if order is None: diff.append((rid, missing_order, flow[2])) elif order[1] ! 1: # 订单非成功 diff.append((rid, status_mismatch, flow[2])) elif abs(float(order[2]) - float(flow[2])) 0.001: diff.append((rid, amount_mismatch, flow[2])) # 4. 输出差异 for d in diff: print(f差异 request_id{d[0]} type{d[1]} amount{d[2]}) return diff if __name__ __main__: reconcile(2025-01-01)逻辑说明脚本以account_flow账户变动流水为基准因为它是资金实际变动的记录订单表可能因为事务或状态机出现偏差。参数day按天跑差异类型分三类缺订单、状态不符、金额不符。跑通后接告警差异不为空就通知值班。这套脚本我建议在压测阶段就跑起来别等上线后才发现对不上。一个具体技巧把request_id的生成规则固定成「终端ID 时间戳 序列号」这样从任何一笔差异都能反查到是哪台终端、哪个时间点的问题排查效率比翻日志高一个量级。我踩过的最大坑就是早期requestId用 UUID出问题后完全无法定位终端后来改成带终端前缀才把对账闭环做起来。希望帮到你。本文还有配套的精品资源点击获取
返回列表