简介:这是一套基于SpringBoot+Vue前后端分离架构的一卡通消费系统源码,面向计算机相关专业的毕业设计、课程设计学生以及需要实战练手的Java开发者。系统整合了Java、JSP、Servlet、MySQL等技术栈,并支持人脸识别、扫码支付、实体卡三种消费方式,能够帮助读者理解前后端分离项目的完整开发流程与业务落地思路。压缩包共748个文件,约1.67MB,其中380个Java文件承载后端业务逻辑,92个Vue文件与82个JS文件构成前端交互界面,另有55个XML、10个VM模板及若干SCSS、YML、Properties等配置资源,目录结构清晰,便于按模块查阅。目前已有90人学习关注。源码均经过本地编译验证,下载后按文档配置环境即可运行,项目难度适中,内容经助教老师审定,适合作为毕设或课设的参考方案,也能为读者提供一卡通消费场景下的功能设计与排错思路。
1. 一卡通消费系统为什么要走前后端分离:从一台食堂 POS 说起
食堂窗口前排队的人最怕什么?不是菜不好吃,是刷卡机卡住。一台 POS 要同时处理实体卡射频读头、扫码枪、人脸摄像头三路输入,还要把扣款结果实时写回后台,任何一路阻塞都会让队伍停摆。这套「基于 SpringBoot + Vue 前后端分离架构的一卡通消费系统,支持人脸、刷码、实体卡」要解决的,就是把三种支付介质统一到一套账户体系里,让消费、充值、对账、权限管理跑在同一套服务上。它适合谁?适合做校园、园区、企业食堂这类封闭场景的开发者,也适合想拿一个真实多介质支付项目练手 SpringBoot 与 Vue 前后端分离的人。核心难点不在界面,而在三路介质如何归一到同一个用户 ID、同一笔流水、同一套幂等扣款逻辑。
2. 三种支付介质怎么归一到一套账户:架构与选型
2.1 为什么用 SpringBoot + Vue 而不是单体 JSP
一卡通系统有个天然矛盾:后台要对接硬件(读卡器、人脸机、扫码枪),前台要给收银员和财务用。如果还用 JSP 把页面和业务揉在一起,改一个收银界面就要重启整个服务,硬件回调一多线程就乱。前后端分离后,Vue 负责收银台、管理后台、报表三套界面,SpringBoot 只暴露 REST 接口,硬件回调走独立 Controller,互不干扰。
选型上,SpringBoot 用 2.7.x 或 3.x 都行,但要注意热词里提到的「springboot版本太高」这个坑:3.x 默认 Jakarta EE 命名空间,很多老的人脸 SDK 示例还是 javax,直接抄会编译不过。我一般新项目直接上 3.2.x,老 SDK 用适配层包一层。Vue 这边用 Vue 3 + Vite,路由用 vue-router,状态管理 Pinia 就够,别上 Vuex 徒增复杂度。
数据库 MySQL 8.0,账户余额字段用 DECIMAL(12,2),绝对不能用 FLOAT,否则对账时会出现 0.01 的玄学误差。缓存用 Redis 存会话和幂等令牌,人脸特征值这种大字段单独存文件或对象存储,数据库只存路径。
2.2 三路介质统一到 user_id 的建模
实体卡、二维码、人脸,本质是三种「凭证」,最终都要映射到一个账户。表设计上分三层:
| 表名 | 作用 | 关键字段 |
|---|---|---|
| t_user | 账户主体 | user_id, name, balance, status |
| t_credential | 凭证映射 | cred_id, user_id, type(card/qr/face), value |
| t_trade | 交易流水 | trade_id, user_id, amount, channel, status, idempotent_key |
t_credential的 type 字段区分介质,value 存卡号、二维码 token 或人脸特征 ID。这样加一种新介质(比如指纹)只需插一条记录,不用改表结构。交易表里 channel 记录实际走的哪一路,方便后续按渠道对账。
CREATE TABLE t_credential ( cred_id BIGINT PRIMARY KEY AUTO_INCREMENT, user_id BIGINT NOT NULL, type VARCHAR(16) NOT NULL COMMENT 'card/qr/face', value VARCHAR(128) NOT NULL COMMENT '卡号/token/特征ID', status TINYINT DEFAULT 1, UNIQUE KEY uk_type_value (type, value), KEY idx_user (user_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;逻辑说明:uk_type_value唯一索引保证同一张卡、同一个二维码 token 不会绑到两个账户,这是防止「一卡多绑」的第一道闸。idx_user让「查某人所有凭证」走索引。参数上 value 长度给 128,人脸特征如果直接存 base64 会超,所以只存特征库里的 ID。
2.3 扣款接口的幂等设计
收银台最怕重复扣款。网络抖动时前端重试、硬件回调重发,都会导致同一笔消费扣两次。做法是前端每次发起消费先生成一个idempotent_key(UUID),随请求带上,后端在 Redis 里SETNX这个 key,成功才继续扣款,失败直接返回上次结果。
@PostMapping("/consume") public Result consume(@RequestBody ConsumeDTO dto) { String key = dto.getIdempotentKey(); // SETNX 成功返回 true,说明是首次请求 Boolean first = redisTemplate.opsForValue() .setIfAbsent("trade:" + key, "1", 5, TimeUnit.MINUTES); if (Boolean.FALSE.equals(first)) { // 重复请求,查上次流水直接返回 return Result.ok(tradeService.findByKey(key)); } return Result.ok(tradeService.doConsume(dto)); }逻辑说明:setIfAbsent对应 Redis 的 SETNX,5 分钟过期足够覆盖一次消费的完整链路。参数上 key 由前端生成,后端不信任前端金额,只信任 key 和 user_id,金额从后端账户表读。注意doConsume里要用数据库行锁或乐观锁扣余额,别只靠 Redis。
3. 人脸、刷码、实体卡三路接入怎么落地
3.1 实体卡:读卡器串口数据到接口
实体卡最常见的是 M1 卡或 CPU 卡,读卡器通过 USB 转串口或网口输出卡号。收银端(Vue 跑在收银机浏览器里)拿不到串口,所以常见做法是本地跑一个小代理程序读串口,再通过 WebSocket 或本地 HTTP 把卡号推给前端。
// 收银端连接本地读卡代理 const ws = new WebSocket('ws://127.0.0.1:9100/card'); ws.onmessage = (evt) => { const cardNo = evt.data.trim(); if (!cardNo) return; // 卡号直接调后端消费接口,channel=card axios.post('/api/consume', { credentialType: 'card', credentialValue: cardNo, amount: currentAmount, idempotentKey: crypto.randomUUID() }); };逻辑说明:本地代理监听串口,读到卡号就推给前端,前端拿到后立即发起消费。参数上credentialValue就是卡号,后端用t_credential查 user_id。注意读卡器可能连续上报同一张卡,前端要做 1 秒去重,否则一次刷卡触发多笔。
3.2 刷码:二维码 token 的生成与校验
刷码分两种:用户出示付款码(被扫),或收银台生成码让用户扫(主扫)。一卡通场景多用被扫,用户在小程序或 App 里生成一个带时效的 token,收银台扫码枪读入后调后端核销。
public String generateQrToken(Long userId) { String token = UUID.randomUUID().toString().replace("-", ""); // 存 Redis,60 秒有效,绑定 userId redisTemplate.opsForValue().set("qr:" + token, userId.toString(), 60, TimeUnit.SECONDS); return token; } public Long resolveQrToken(String token) { String uid = redisTemplate.opsForValue().get("qr:" + token); if (uid == null) throw new BizException("二维码已过期"); // 核销后立即删除,防止一码多用 redisTemplate.delete("qr:" + token); return Long.valueOf(uid); }逻辑说明:token 存 Redis 并设 60 秒过期,核销即删,保证一码一用。参数上 60 秒是经验值,太短用户来不及出示,太长有被盗刷风险。扫码枪本质是键盘输入设备,前端监听回车即可拿到 token。
3.3 人脸:特征提取与比对放在哪一层
人脸这块最容易翻车。热词里出现的 arcface、viewfacecore、easyai 都是常见方案,但关键问题是:特征提取和比对放在前端、后端还是人脸机本地?
我的经验是分场景。如果用人脸门禁机(带算力的终端),比对在终端本地完成,终端只把 user_id 和置信度传给后端,后端不碰图像,隐私和性能都好。如果用普通摄像头,图像传到后端,用 SDK 提取特征再和特征库比对。
// 后端人脸比对伪代码,特征库用向量检索 public FaceResult matchFace(byte[] image) { float[] feature = faceSdk.extract(image); // 提取 512 维特征 if (feature == null) return FaceResult.noFace(); // 在特征库中找最近邻,阈值 0.6 左右 MatchItem item = faceIndex.search(feature, 0.6f); if (item == null) return FaceResult.noMatch(); return FaceResult.ok(item.getUserId(), item.getScore()); }逻辑说明:extract返回归一化后的特征向量,search做余弦相似度检索,阈值 0.6 是常见起点,实际要按误识率和拒识率调。参数上特征维度取决于 SDK,arcface 常见 512 维。注意人脸比对只是「识别」,扣款还要走账户校验,别把识别结果直接当支付成功。
提示:人脸数据属于敏感信息,存储要加密,传输走 HTTPS,别把原始人脸图长期留在服务器上。
4. 避坑与排查:三路介质联调时最容易翻车的五件事
4.1 刷卡扣了钱但界面没反应
现象:收银员看到读卡器亮灯,但收银界面没跳成功,用户却收到扣款短信。原因:前端发起消费后,后端已扣款,但响应在返回途中丢失,前端超时重试又带了新 key,导致重复扣。解决:前端超时后不要生成新 key,用原 key 重试;后端消费接口必须幂等,重复 key 直接返回上次结果。
4.2 二维码扫一次能用两次
现象:同一张付款码被两个收银台先后扫,都扣款成功。原因:核销逻辑用了get后delete,两个请求并发时都读到了 token。解决:用 Redis 的GETDEL或 Lua 脚本原子化「读+删」,保证只有一个请求能拿到 userId。
4.3 人脸识别在逆光下集体失灵
现象:中午阳光直射窗口,人脸机识别率骤降,用户反复摘口罩。原因:摄像头动态范围不够,逆光时人脸区域过暗,特征提取失败。解决:调整摄像头安装角度避开直射,或换宽动态摄像头;软件侧加图像预处理(直方图均衡),但别指望算法能救硬件。
4.4 SpringBoot 3 升级后人脸 SDK 编译不过
现象:项目从 2.7 升到 3.x,人脸 SDK 的javax.servlet报找不到。原因:SpringBoot 3 迁移到 Jakarta EE,包名从 javax 变 jakarta。解决:优先找 SDK 的新版本;找不到就用适配层,把 SDK 依赖隔离在独立模块,用 javax 编译,通过接口和主应用通信。
4.5 余额对不上,差几分钱
现象:财务对账时总账和流水差 0.01 到 0.05。原因:余额字段用了 FLOAT/DOUBLE,累加产生浮点误差;或并发扣款时没用行锁,出现超扣。解决:金额一律 DECIMAL,Java 侧用 BigDecimal;扣款 SQL 用UPDATE ... SET balance = balance - ? WHERE user_id = ? AND balance >= ?,靠数据库原子性防超扣。
5. 把三路介质压测到 500 TPS:验证方法与一个收尾技巧
系统能不能上生产,不看功能全不全,看并发下会不会乱。我一般用 JMeter 或 wrk 对消费接口做压测,重点观察三个指标:扣款成功率、余额一致性、幂等命中率。
压测前先造数据:1000 个账户,每个账户绑一张卡、一个二维码、一条人脸特征。然后用脚本并发打消费接口,channel 随机在三路里选。
# 用 wrk 压消费接口,带幂等 key wrk -t8 -c200 -d60s --latency \ -s consume.lua \ http://localhost:8080/api/consumeconsume.lua里每次请求生成唯一 key,body 里随机 user_id 和 channel。压完查两件事:一是t_trade里有没有重复 key,二是所有账户的balance + 已消费金额是否等于初始余额。这两个对得上,基本就稳了。
一个收尾技巧:把三路介质的消费入口收敛到一个ConsumeService.consume(userId, amount, channel, idempotentKey)方法,实体卡、二维码、人脸三条链路只负责「解析出 userId」,解析完统一调这个方法。这样扣款逻辑只有一份,加介质不用动核心,出问题也只查一个地方。我早期图省事给每路写了一套扣款,结果改一个费率要改三处,血泪教训。
人脸这块再补一句:别迷信 SDK 给的置信度阈值,一定要用自己场景的真实数据跑一遍 ROC,找到误识率和拒识率的平衡点。我见过直接抄 demo 阈值 0.8 的,结果自己员工都刷不过。希望帮到你。
本文还有配套的精品资源,点击获取