资讯详情

SpringBoot+Vue一卡通消费系统:多支付统一扣款与幂等对账

📅 2026/10/29 18:11:00 | 华诺云谱 👁 阅读
SpringBoot+Vue一卡通消费系统:多支付统一扣款与幂等对账
简介这是一套基于 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 的 XMLRedis 用来存刷码 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 requestbaseURL写成/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、signsign 一般是deviceNo timestamp secret的哈希后端用设备表里的 secret 重算比对。不验签的话任何人抓包拿到接口就能伪造扣款请求。PostMapping(/api/consume) public ResultConsumeResult 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?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai username: root password: root redis: host: 127.0.0.1 port: 6379serverTimezoneAsia/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:8080server: { 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加唯一索引扣款前先查高并发场景再加 Redissetnxkey 用consume:req:{requestId}过期时间 5 分钟。数据库唯一索引是最后一道防线不能省。现象账户余额出现负数或者两个窗口同时扣款只扣成功一次。原因扣款 SQL 写成了update account set balance balance - #{amount} where id #{id}没有余额判断并发下两个事务都读到旧余额。解决把条件写进 SQLUPDATE 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 序列化没设时区。解决连接串加serverTimezoneAsia/Shanghai实体类时间字段加JsonFormat(pattern yyyy-MM-dd HH:mm:ss, timezone GMT8)两边都对齐。6. 二次开发新增支付方式和报表导出的改造入口真要在这套系统上做二次开发优先动三个地方支付策略、报表导出、多园区隔离。新增一种支付方式比如工牌 NFC 或指纹步骤是固定的先加PayChannel枚举再写一个PaymentStrategy实现类channel()返回新枚举consume()里把凭证解析成accountId最后调accountService.deduct()。工厂是启动时自动收实现类的不用改路由代码。唯一要注意的是设备端要能上报新渠道的凭证并在device表里配好签名 secret。报表导出建议单独建一个ReportService不要在订单查询接口上挂 Excel 导出。消费流水动辄几十万行用 EasyExcel 分批查、分批写每批 5000 行避免一次性把结果集全塞进内存。对账报表按“设备流水 vs 平台订单”做全量比对差异表落库别只打日志否则出了问题没人知道差在哪。多园区隔离最简单的做法是给账户、设备、订单都加tenant_idMyBatis-Plus 用租户插件自动拼条件。但消费接口是终端调的终端不一定带租户号得从device_no反查租户再塞进上下文。这一步漏了A 园区的终端能扣 B 园区的账户属于上线前必须用脚本验一遍的项。验证改造是否成功我一般跑两个动作用 JMeter 对同一个requestId并发 500 次看订单表是不是只有一条再手动改一条订单状态为REVERSED跑一遍对账任务看差异表能不能把冲正流水算进去。这两个动作过了再谈页面好不好看。从那以后我每次接新支付渠道都先把幂等键和冲正流程走一遍再动前端。希望帮到你。本文还有配套的精品资源点击获取
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑