☰
SpringBoot+Vue一卡通消费系统:多支付统一扣款与幂等对账
2026/10/29 18:10:57 网站建设 项目流程

简介:这是一套基于 SpringBoot 与 Vue 前后端分离架构的一卡通消费系统源码,后端技术栈整合 SpringBoot、Java 与 MySQL 等常见组件,围绕人脸识别、扫码支付与实体卡刷卡等消费场景展开,适合计算机专业学生用于毕业设计、课程设计或 Java Web 全栈练手,也可作为校园卡类业务原型的参考实现。压缩包共 748 个文件,约 1.67MB,包含 380 个 Java 后端源码、92 个 Vue 前端组件,以及 SVG 图标、JavaScript、XML 配置、SCSS 样式和 YAML、properties 配置等,前后端分层与静态资源组织较完整。已有 91 人学习,说明项目具备一定参考热度。读者可从中了解一卡通消费的账户、刷卡刷码与人脸识别等业务模块划分,学习 SpringBoot 接口开发、Vue 页面组织、MySQL 数据配置与前后端联调思路,并对照项目结构梳理环境配置和运行要点,目录中前后端代码、静态资源和配置文件相对齐全,便于按模块拆解阅读,适合需要完整可运行项目案例与全栈实践参考的人群。

1. 一卡通消费系统:三种支付方式并存时的真实麻烦

中午十二点,食堂窗口前排着三队人:一队掏实体卡贴读卡器,一队举着手机亮付款码,还有一队直接站在人脸终端前刷脸。三种支付方式混在同一个窗口,收银员不切系统,后台最终只看到一条扣款流水——这就是一套 SpringBoot + Vue 前后端分离的一卡通消费系统要处理的日常。它面向学校、园区、企业食堂这类有固定持卡人、有多台消费终端、需要统一对账的场景。后端管账户、交易、设备和权限,前端管发卡、充值、报表和终端管理,人脸、刷码、实体卡只是三种不同的凭证形式,最后都落到同一个扣款入口。谁在什么时候、用什么方式、扣了多少钱、余额还剩多少,这四件事必须对得上,否则月底对账就是一场灾难。

2. 技术栈与工程结构:SpringBoot 和 Vue 各自管什么

这套项目的前后端边界划得比较清楚:后端只暴露 REST 接口,不渲染页面;前端独立打包,开发时靠代理转发,生产时靠 Nginx 转发。先说技术选型,再说目录怎么摆,最后看数据库里绕不开的几张表。

2.1 后端分层:controller、service、mapper 的边界

后端常见做法是 SpringBoot + MyBatis-Plus + MySQL + Redis。SpringBoot 负责 Web 容器、依赖注入和事务,MyBatis-Plus 省掉单表 CRUD 的 XML,Redis 用来存刷码 token、设备在线状态和接口幂等键。JWT 做登录态,拦截器校验 token,管理端和消费终端走两套鉴权:管理端按角色控菜单,终端按设备号和签名控接口。

包结构一般这样摆:

com.example.canteen ├── controller # 管理端接口、终端消费接口 ├── service # 账户、订单、设备、对账 │ └── impl ├── mapper # MyBatis-Plus Mapper ├── entity # 数据库实体 ├── dto # 请求/响应对象 ├── strategy # 支付策略(卡、码、人脸) ├── config # 跨域、拦截器、Redis、线程池 ├── task # 对账定时任务 └── common # 统一返回、异常、工具类

这里有个容易翻车的边界:controller 不写业务判断,只做参数校验和鉴权;service 不直接拼 HTTP 响应,返回业务对象;mapper 不写跨表逻辑。消费扣款这种涉及账户、订单、流水的操作,必须放在同一个 service 事务里,否则网络一抖就会出现“钱扣了、订单没写”的黑匣子。我一般把ConsumeService单独拎出来,策略类只负责“凭证解析”,真正扣款统一调AccountService.deduct(),这样三种支付方式不会各写一套扣款逻辑。

2.2 前端工程:Vue3 + Element Plus 的页面组织

前端常见组合是 Vue3 + Vite + Element Plus + Axios + Vue Router + Pinia。管理端页面不复杂,难的是状态和权限:登录后拿菜单树,路由动态挂载,按钮级权限用指令控制。目录一般按业务域分:

src ├── api # 按模块拆:account.js、order.js、device.js ├── views │ ├── dashboard # 首页统计 │ ├── account # 账户管理、充值、冻结 │ ├── card # 实体卡发卡、挂失 │ ├── order # 消费流水、冲正 │ ├── device # 消费终端管理 │ └── report # 对账报表 ├── router ├── store ├── utils/request.js # Axios 封装 └── components

请求封装要统一处理 token、错误码和 401 跳转,别每个页面自己写一遍 Axios。下面这段是常见的request.js写法:

import axios from 'axios' import router from '@/router' const request = axios.create({ baseURL: '/api', timeout: 10000 }) request.interceptors.request.use(config => { const token = localStorage.getItem('token') if (token) config.headers.Authorization = `Bearer ${token}` return config }) request.interceptors.response.use( res => { // 后端统一返回 { code, msg, data } if (res.data.code !== 0) { ElMessage.error(res.data.msg || '请求失败') return Promise.reject(new Error(res.data.msg)) } return res.data.data }, err => { if (err.response?.status === 401) { localStorage.removeItem('token') router.push('/login') } return Promise.reject(err) } ) export default request

baseURL写成/api而不是完整域名,开发时交给 Vite 代理,生产时交给 Nginx,前端代码不用改。token 放 localStorage 是图省事,真要防 XSS 得换 HttpOnly Cookie,这是后话。响应拦截里判断code !== 0统一弹错,页面里只处理成功分支,代码会干净很多。

2.3 数据库核心表:账户、卡、订单、设备

一卡通系统绕不开六张表:账户、实体卡、人脸档案、消费订单、充值订单、消费终端。字段不用多,但几个关键约束必须建对。

CREATE TABLE account ( id BIGINT PRIMARY KEY AUTO_INCREMENT, user_id BIGINT NOT NULL, balance DECIMAL(10,2) NOT NULL DEFAULT 0.00, status TINYINT NOT NULL DEFAULT 1 COMMENT '1正常 0冻结', version INT NOT NULL DEFAULT 0, UNIQUE KEY uk_user (user_id) ); CREATE TABLE card ( id BIGINT PRIMARY KEY AUTO_INCREMENT, card_no VARCHAR(32) NOT NULL, account_id BIGINT NOT NULL, status TINYINT NOT NULL DEFAULT 1 COMMENT '1正常 2挂失 3注销', UNIQUE KEY uk_card_no (card_no) ); CREATE TABLE consume_order ( id BIGINT PRIMARY KEY AUTO_INCREMENT, request_id VARCHAR(64) NOT NULL, account_id BIGINT NOT NULL, channel VARCHAR(16) NOT NULL COMMENT 'CARD/QR/FACE', amount DECIMAL(10,2) NOT NULL, balance_after DECIMAL(10,2) NOT NULL, device_no VARCHAR(32) NOT NULL, status VARCHAR(16) NOT NULL COMMENT 'SUCCESS/FAILED/REVERSED', create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_request_id (request_id), KEY idx_account_time (account_id, create_time) ); CREATE TABLE device ( id BIGINT PRIMARY KEY AUTO_INCREMENT, device_no VARCHAR(32) NOT NULL, secret VARCHAR(64) NOT NULL, location VARCHAR(64), status TINYINT NOT NULL DEFAULT 1, UNIQUE KEY uk_device_no (device_no) );

consume_order.request_id的唯一索引是整个系统的后悔药,终端重试、网络超时补传、用户连点,最后都靠它兜底。account.balance用DECIMAL不用DOUBLE,金额计算别给自己找精度麻烦。card_no和device_no都加唯一索引,导入数据时重复会直接报错,比事后查脏数据省事。人脸档案表一般存user_id、face_id、feature_version、status,人脸特征本身不放数据库,放人脸库或对象存储,平台只存映射关系。

3. 三种支付方式统一消费链路:实体卡、刷码、人脸怎么走同一个扣款入口

三种支付方式看起来差异很大,落到代码里其实只有“凭证解析”不同:实体卡解析出卡号,刷码解析出一次性 token,人脸解析出 faceId 或 userId。解析完之后,校验账户、扣余额、写订单、返回结果,这条链路必须共用。

3.1 支付渠道抽象:策略模式把差异关进三个类里

先定一个策略接口,三个实现类分别处理卡、码、人脸。工厂按渠道枚举路由,新增支付方式时只加一个实现类,不动扣款主流程。

public interface PaymentStrategy { PayChannel channel(); ConsumeResult consume(ConsumeCommand command); } @Component public class CardPaymentStrategy implements PaymentStrategy { @Resource private CardMapper cardMapper; @Resource private AccountService accountService; @Override public PayChannel channel() { return PayChannel.CARD; } @Override public ConsumeResult consume(ConsumeCommand cmd) { // 实体卡:卡号查绑定账户,校验卡状态 Card card = cardMapper.selectByCardNo(cmd.getCredential()); if (card == null || card.getStatus() != 1) { return ConsumeResult.fail("卡不存在或已挂失"); } return accountService.deduct(card.getAccountId(), cmd.getAmount(), cmd.getRequestId()); } }

channel()返回枚举,工厂启动时把实现类收进 Map,消费接口按cmd.getChannel()取策略。credential对不同渠道含义不同:卡是物理卡号,码是二维码里的 token,人脸是设备识别后的 faceId。requestId由终端生成,全局唯一,后面幂等和对账都靠它。

刷码策略要处理一次性问题,常见做法是 Redis 存 token,消费时用getAndDelete原子取删:

@Component public class QrCodePaymentStrategy implements PaymentStrategy { @Resource private StringRedisTemplate redisTemplate; @Resource private AccountService accountService; @Override public PayChannel channel() { return PayChannel.QR; } @Override public ConsumeResult consume(ConsumeCommand cmd) { String key = "pay:qr:" + cmd.getCredential(); // 原子取删,保证同一个码只能被消费一次 String userId = redisTemplate.opsForValue().getAndDelete(key); if (userId == null) { return ConsumeResult.fail("二维码已过期或已使用"); } return accountService.deduct(Long.valueOf(userId), cmd.getAmount(), cmd.getRequestId()); } }

getAndDelete在 Redis 6.2 以上和 Spring Data Redis 较新版本可用,老环境用 Lua 脚本包一层也行。关键是“取”和“删”必须原子,否则两个终端同时扫同一个码,都能取到 userId,就会重复扣款。二维码 token 的 TTL 一般设 60 秒,前端每 30 秒刷新一次,过期就重新生成。

人脸策略更薄,因为 1:N 比对通常在终端完成,平台只做二次校验:

@Component public class FacePaymentStrategy implements PaymentStrategy { @Resource private FaceProfileMapper faceProfileMapper; @Resource private AccountService accountService; @Override public PayChannel channel() { return PayChannel.FACE; } @Override public ConsumeResult consume(ConsumeCommand cmd) { // 终端上报 faceId,平台查映射并校验档案状态 FaceProfile profile = faceProfileMapper.selectByFaceId(cmd.getCredential()); if (profile == null || profile.getStatus() != 1) { return ConsumeResult.fail("人脸档案不存在或已停用"); } // featureVersion 防止终端用旧特征库扣款 if (!profile.getFeatureVersion().equals(cmd.getFeatureVersion())) { return ConsumeResult.fail("人脸特征版本不一致,请同步"); } return accountService.deduct(profile.getUserId(), cmd.getAmount(), cmd.getRequestId()); } }

featureVersion是我建议一定要加的字段。终端离线时会缓存人脸特征库,如果平台这边已经换库或删人,旧终端还能识别出已经注销的用户,扣款就会落到一个异常账户上。版本号对不上直接拒绝,让终端先同步,比事后冲正省事。

3.2 实体卡刷卡:从读卡到扣款的完整时序

实体卡链路最短,但设备鉴权不能省。终端每次消费都要带deviceNo、timestamp、sign,sign 一般是deviceNo + timestamp + secret的哈希,后端用设备表里的 secret 重算比对。不验签的话,任何人抓包拿到接口就能伪造扣款请求。

@PostMapping("/api/consume") public Result<ConsumeResult> consume(@RequestBody @Valid ConsumeRequest req) { // 1. 设备验签,防伪造终端 if (!deviceService.checkSign(req.getDeviceNo(), req.getTimestamp(), req.getSign())) { return Result.fail("设备签名校验失败"); } // 2. 幂等:同一 requestId 直接返回上次结果 ConsumeOrder exist = orderService.getByRequestId(req.getRequestId()); if (exist != null) { return Result.ok(ConsumeResult.of(exist)); } // 3. 路由到具体支付策略 PaymentStrategy strategy = strategyFactory.get(req.getChannel()); return Result.ok(strategy.consume(req.toCommand())); }

checkSign里要做时间窗校验,比如 timestamp 和服务器时间差超过 5 分钟就拒绝,防止重放。getByRequestId走唯一索引,查到就直接返回,终端重试不会产生第二条订单。这两步放在 controller 里还是拦截器里都行,关键是别放进策略类,否则每个策略都要抄一遍。

3.3 刷码与人脸:校验差异和终端配合

刷码的凭证是动态 token,平台侧不需要知道用户是谁,Redis 里存了映射。终端扫到码之后把 token 和金额一起上报,平台取删 token 再扣款。这里有个细节:如果扣款失败,token 已经被删了,用户得重新亮码。所以常见做法是先扣款再删 token,或者用 Lua 把“查余额 + 扣款 + 删 token”做成原子操作。项目里如果没做,至少要在失败时把 token 写回去,TTL 设短一点,比如 30 秒。

人脸支付的凭证是 faceId,平台不存人脸图片,只存 faceId 和 userId 的映射。终端识别成功后上报 faceId、置信度、特征版本,平台校验置信度阈值和版本号。置信度阈值一般设 0.8 以上,太低容易误识别,太高用户得反复抬头。这个值不要写死在代码里,放设备配置表,不同光照环境的食堂可以调。

三种渠道最后都调accountService.deduct(),这个方法只认accountId、amount、requestId,不关心凭证从哪来。新增指纹、NFC 工牌、身份证,都只需要加一个策略类和一个渠道枚举,主链路不动。

3.4 消费订单状态机与幂等设计

订单状态不要用一堆 if-else 散着写,定一个简单状态机:INIT -> SUCCESS,失败是FAILED,冲正后是REVERSED。消费成功后不允许直接改金额,只能走冲正:生成一条反向流水,原订单标记REVERSED。这样报表里能同时看到正向和反向,账不会乱。

幂等分两层:第一层是request_id唯一索引,数据库兜底;第二层是 Redissetnx,挡掉高并发下的重复请求。只靠数据库唯一索引也能防住,但重复请求会打到数据库,量大了容易把连接池打满。我一般让终端生成 requestId 时带上设备号和本地流水号,比如D001-20240512-000123,这样即使终端重启,流水号也不会从头开始撞车。

4. 本地跑起来:建库、改配置、前后端联调的三段式

拿到源码后别急着mvn spring-boot:run,先把 MySQL 和 Redis 起好,库表和配置对一遍,再启动后端,最后起前端。顺序反了,报错会一层套一层,排查起来很耗时间。

4.1 环境准备与依赖版本

需要 JDK 8 或 11(看项目 pom 里的java.version)、Maven 3.6+、MySQL 8、Redis 6+、Node 16+。MySQL 建库字符集用utf8mb4,排序规则utf8mb4_general_ci,不然姓名里的生僻字会变成问号。

CREATE DATABASE canteen DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;

导入项目里的sql/canteen.sql,一般会建表加基础数据:默认管理员、测试设备、测试账户。导入后检查account和device有没有数据,没有的话消费接口调不通。Redis 不需要建库,直接redis-server起本地实例即可,默认端口 6379。

4.2 后端启动与接口自测

改application.yml里的数据源和 Redis 地址,时区参数别漏:

server: port: 8080 spring: datasource: url: jdbc:mysql://127.0.0.1:3306/canteen?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai username: root password: root redis: host: 127.0.0.1 port: 6379

serverTimezone=Asia/Shanghai不加,消费订单的时间可能差 8 小时,对账时按天分组会直接把凌晨的单子算到前一天。启动命令:

mvn clean package -DskipTests java -jar target/canteen-0.0.1-SNAPSHOT.jar

看到Started CanteenApplication就说明后端起来了。先用 curl 测登录,再测消费:

curl -X POST http://127.0.0.1:8080/api/auth/login \ -H "Content-Type: application/json" \ -d '{"username":"admin","password":"123456"}'

返回 token 后,用测试设备号调消费接口。注意sign要按项目里的签名规则算,一般是md5(deviceNo + timestamp + secret),secret 在device表里。签名算错会直接返回“设备签名校验失败”,不是代码问题,别急着改源码。

4.3 前端启动与跨域处理

前端npm install之后先看vite.config.js或vue.config.js里的代理有没有指向http://127.0.0.1:8080:

server: { port: 5173, proxy: { '/api': { target: 'http://127.0.0.1:8080', changeOrigin: true } } }

然后npm run dev,浏览器打开http://localhost:5173。登录后先看账户列表和消费流水有没有数据,再点“发卡”和“充值”走一遍。前端打包用npm run build,产物在dist,生产环境用 Nginx 把/api转发到后端,别把baseURL写死成localhost,否则上线必翻车。

5. 避坑排查:从重复扣款到时区错乱的 5 个真实记录

这套系统最容易出问题的地方不是页面,是并发、设备和时间。下面五条是这类项目里高频出现的坑,每条按现象、原因、解决说。

5.1 后端与数据库:重复扣款和余额扣成负数

现象:同一个用户同一分钟出现两笔金额相同的消费,余额被扣了两次。
原因:终端网络超时后自动重试,平台没有按requestId做幂等,两次请求都进了扣款逻辑。
解决:consume_order.request_id加唯一索引,扣款前先查;高并发场景再加 Redissetnx,key 用consume:req:{requestId},过期时间 5 分钟。数据库唯一索引是最后一道防线,不能省。

现象:账户余额出现负数,或者两个窗口同时扣款只扣成功一次。
原因:扣款 SQL 写成了update account set balance = balance - #{amount} where id = #{id},没有余额判断,并发下两个事务都读到旧余额。
解决:把条件写进 SQL:

UPDATE account SET balance = balance - #{amount}, version = version + 1 WHERE id = #{accountId} AND status = 1 AND balance >= #{amount}

返回影响行数为 0 就抛“余额不足”,别在 Java 里先查再扣,查和扣之间有时间窗。

5.2 前端与设备:打包后 404、人脸版本不一致

现象:开发环境接口正常,npm run build部署后所有请求 404。
原因:Vite 的proxy只在开发服务器生效,生产环境没有转发规则。
解决:Nginx 加一段location /api/ { proxy_pass http://后端地址:8080/; },前端baseURL保持/api,不要改成完整域名。

现象:人脸终端识别成功,平台提示“人脸档案不存在”,但管理端能看到这个人。
原因:终端缓存了旧特征库,上报的faceId是旧库里的 ID,平台换库后映射对不上。
解决:人脸档案加feature_version,终端每次同步拉版本号,消费时带上;平台比对不一致直接拒绝,让终端先同步再交易。

现象:消费订单的时间比实际时间晚 8 小时,按天统计报表对不上。
原因:MySQL 连接串没配serverTimezone,或者 Jackson 序列化没设时区。
解决:连接串加serverTimezone=Asia/Shanghai,实体类时间字段加@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8"),两边都对齐。

6. 二次开发:新增支付方式和报表导出的改造入口

真要在这套系统上做二次开发,优先动三个地方:支付策略、报表导出、多园区隔离。新增一种支付方式,比如工牌 NFC 或指纹,步骤是固定的:先加PayChannel枚举,再写一个PaymentStrategy实现类,channel()返回新枚举,consume()里把凭证解析成accountId,最后调accountService.deduct()。工厂是启动时自动收实现类的,不用改路由代码。唯一要注意的是设备端要能上报新渠道的凭证,并在device表里配好签名 secret。

报表导出建议单独建一个ReportService,不要在订单查询接口上挂 Excel 导出。消费流水动辄几十万行,用 EasyExcel 分批查、分批写,每批 5000 行,避免一次性把结果集全塞进内存。对账报表按“设备流水 vs 平台订单”做全量比对,差异表落库,别只打日志,否则出了问题没人知道差在哪。

多园区隔离最简单的做法是给账户、设备、订单都加tenant_id,MyBatis-Plus 用租户插件自动拼条件。但消费接口是终端调的,终端不一定带租户号,得从device_no反查租户,再塞进上下文。这一步漏了,A 园区的终端能扣 B 园区的账户,属于上线前必须用脚本验一遍的项。

验证改造是否成功,我一般跑两个动作:用 JMeter 对同一个requestId并发 500 次,看订单表是不是只有一条;再手动改一条订单状态为REVERSED,跑一遍对账任务,看差异表能不能把冲正流水算进去。这两个动作过了,再谈页面好不好看。从那以后我每次接新支付渠道,都先把幂等键和冲正流程走一遍,再动前端。希望帮到你。

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询