☰
基于SpringBoot+Vue的一卡通消费系统三介质交易链路实战
2026/10/14 4:08:10 网站建设 项目流程

简介:这是一份基于Spring Boot与Vue前后端分离架构的一卡通消费系统源码包,适用于高校学生完成毕业设计或课程设计。系统集成人脸识别、二维码刷码及实体卡三种消费方式,覆盖日常消费管理场景;后端以Java为主,前端由Vue构建,并搭配JS、SVG、XML等辅助文件,共748个文件,压缩包大小约1.67MB,结构清晰便于按模块查阅。目前已有90人学习下载。资源内源码均经本地编译验证,可正常运行,附带环境配置文档,下载后按说明搭建MySQL等依赖即可启动;同时包含启动脚本、打包脚本等运维辅助文件,帮助使用者快速上手。整体难度适中,适合具备一定Java基础、需要完整项目参考的读者直接使用或二次开发。

1. 一卡通消费系统的本质:不是信息管理系统,而是三介质的统一交易链路

基于springboot+vue前后端分离架构的一卡通消费系统,第一次被我拿来接手时,我先看的不是 Controller,也不是 Vue 页面,而是数据库脚本。因为这类系统最容易被外行看低,觉得不过是一套「增删改查」,真正碰过的人才知道,难点在于它同时要承载人脸、刷码、实体卡三种消费介质,还要保证任何一笔消费不重扣、不漏记、对得上账。

这套系统最常见的落地场景是校园食堂、园区便利店、企业厂区的小卖部:消费终端五花八门,管理后台要管发卡、充值、流水、对账,而服务端要统一面对所有终端。标题里那个 .zip 只是交付形态,真正值钱的是消费链路的建模方式。本文我就按「账户模型 → 交易链路 → 最小系统 → 避坑 → 上线加固」的顺序讲,适合想自研卡务平台或要接手这类项目的团队,也适合刚入门前后端分离但没接触过支付类业务的开发者。

2. 对应关系与核心表:三介质共享一个资金账户,表设计先于代码

2.1 为什么 springboot+vue 前后端分离适合一卡通消费场景

一卡通系统从来不是「只有一个后台」的软件。消费机上要跑终端程序,管理员要用 PC 后台,用户可能还要用 H5 或小程序查余额、充值和挂失。如果做成单体应用,页面和交易逻辑耦合在一起,每接一个厂商的消费终端都要动一次主工程,风险很高。

前后端分离的价值就是把「交易接口」和「页面展示」彻底切开。Spring Boot 这边只输出 HTTP 接口,负责介质识别、账户扣款、流水落库;Vue 那边只做管理端,通过 axios 调接口完成发卡、查询、对账。终端厂商不用关心页面长什么样,只需要和你们约定一套接口协议。我一般的做法是动手写代码之前,先把消费接口的公共入参数固定死,字段一改,后面全是坑。

参数说明类型约束
mediumType介质类型:0 实体卡,1 二维码,2 人脸int必填
mediumNo卡号 / 动态码 token / faceIdstring必填,最长 64
amountCents扣款金额,单位分long必填,大于 0
bizNo业务幂等号:设备号+日期+终端流水string必填,全局唯一
deviceNo发起消费的终端编号string必填,用于对账和溯源

这张表里最容易漏掉的是 bizNo。很多团队最开始只传卡号和金额,等遇到「消费机断网重发导致扣两笔」才回来补。后文会专门讲它为什么是资金安全的命门。

2.2 三张核心表的结构与字段设计理由

一卡通消费系统的表可以很多,但核心只有三张:资金账户表、消费介质表、交易流水表。其余的发卡记录、充值记录、设备表都是围绕这三张展开的。先看 DDL,后面所有的接口和避坑都从这里来。

-- 资金账户表:余额的唯一载体 CREATE TABLE t_account ( account_id BIGINT NOT NULL AUTO_INCREMENT COMMENT '账户ID', account_no VARCHAR(32) NOT NULL COMMENT '账户编号,对外查询用', balance_cents BIGINT NOT NULL DEFAULT 0 COMMENT '余额,单位分,禁止用小数', status TINYINT NOT NULL DEFAULT 1 COMMENT '1正常 0冻结', created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (account_id), UNIQUE KEY uk_account_no (account_no) ) COMMENT '资金账户'; -- 消费介质表:三种介质都挂到同一个 account_id CREATE TABLE t_medium ( medium_id BIGINT NOT NULL AUTO_INCREMENT COMMENT '介质ID', account_id BIGINT NOT NULL COMMENT '账户ID', medium_type TINYINT NOT NULL COMMENT '0实体卡 1二维码 2人脸', medium_no VARCHAR(64) NOT NULL COMMENT '卡号/动态码标识/faceId', bind_status TINYINT NOT NULL DEFAULT 1 COMMENT '1已绑定 0已解绑', created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (medium_id), UNIQUE KEY uk_medium (medium_type, medium_no) ) COMMENT '消费介质'; -- 交易流水表:只追加,不修改,不删除 CREATE TABLE t_trade ( trade_id BIGINT NOT NULL AUTO_INCREMENT, biz_no VARCHAR(40) NOT NULL COMMENT '幂等键:设备号+日期+终端流水', account_id BIGINT NOT NULL COMMENT '扣款账户', medium_type TINYINT NOT NULL COMMENT '本次消费使用的介质类型', amount_cents BIGINT NOT NULL COMMENT '扣款金额(分)', balance_after BIGINT NOT NULL COMMENT '扣款后余额快照,对账用', status TINYINT NOT NULL DEFAULT 1 COMMENT '1成功 0已冲正', device_no VARCHAR(32) NOT NULL COMMENT '消费终端编号', created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (trade_id), UNIQUE KEY uk_biz_no (biz_no) ) COMMENT '交易流水';

三个设计点需要重点说明。第一,余额一律用 BIGINT 存「分」,不要用 decimal,更不要用 float。哪怕 decimal 能精确表示两位小数,和多终端、多笔累加后只要有一次四舍五入口径不同,对账就会差几分钱,排查成本远高于设计成本。第二,介质表要有 (medium_type, medium_no) 的唯一索引,由数据库兜底防止同一张卡被并发地绑定到两个账户。第三,流水表存 account_id 而不存卡号,因为介质可能被解绑再绑,流水要保留的是「当时用的是哪类介质」,而不是会变的卡号。

流水表里我还特意留了 balance_after,也就是扣款后的余额快照。有人觉得冗余,但对账时它能直接还原每一笔交易发生后的账户状态,不需要靠交易时间倒推。只有追加、不更新,才能在出问题时不破坏历史现场。

2.3 介质绑定 API:换卡、补卡、解绑的边界条件

介质绑定是很容易写错的一层。业务上必须满足三个约束:一张卡或一个 faceId 在同一时刻只能属于一个账户;一个账户可以绑多种介质;解绑动作不能影响历史流水。具体做的时候还要分清在线介质和离线介质——实体卡和人脸通常要在终端本地做黑名单同步,这里先给一段后端绑定接口的核心逻辑。

@PostMapping("/api/medium/bind") public Result bind(@RequestBody BindRequest req) { // 1. 同一介质不能重复绑定 int dup = mediumMapper.countByTypeAndNo(req.getMediumType(), req.getMediumNo()); if (dup > 0) { return Result.fail("该介质已绑定其他账户"); } // 2. 人脸介质需要确认终端已准备好接收特征 if (req.getMediumType() == 2) { faceService.checkTerminalReady(req.getDeviceNo(), req.getMediumNo()); } // 3. 写入介质表,状态置为已绑定 mediumMapper.insert(req.getAccountId(), req.getMediumType(), req.getMediumNo()); // 4. 人脸特征与实体卡黑名单通过消息异步同步到终端 if (req.getMediumType() == 2) { faceSyncProducer.send(req.getDeviceNo(), req.getMediumNo()); } return Result.ok(); }

这段代码里有几个细节。第一步的查重依赖 2.2 节那张表的唯一索引,就算两个请求同时进来,数据库也会把第二条挡住;第二步只适合人脸等需要终端配合的介质,实体卡不需要这一步;第四步为什么用异步?因为终端同步特征可能要几十毫秒,放在主链路里会拖慢接口响应,而且终端离线时不能阻塞绑定。你还需要一个解绑接口,解绑时把 bind_status 置 0 而不是物理删除,这样流水表才能继续按介质号回溯。

常见做法是补卡时「先解绑旧卡,再绑定新卡」。顺序反了会导致旧卡解绑失败时新卡已经写进库,出现一个账户两张可用卡。我的习惯是先走解绑,确认成功了再走绑定,两步都是独立事务。

3. 消费交易链路:鉴权、扣款、落流水、异步通知的完整时序

3.1 三介质消费的共同链路与差异点

不管什么介质,一笔消费在服务端都只有四步:识别介质、映射账户、余额扣减、落流水。差异只在前两步怎么拿到介质标识,以及要不要依赖终端本地处理。实体卡最常见的是刷 IC 卡,消费机读卡号后把卡号和金额传上来;刷码由手机生成动态二维码,消费机扫码后拿到 token,再交给后端校验;人脸则是终端本地先做比对,比对通过后把 faceId 传上来。

三者在「在线还是离线」上差异很大。实体卡和人脸很多项目都做了离线兜底:终端本地记录余额或白名单,断网时先放行,恢复联网后再上传流水;扫码基本只能在线,因为 token 的有效期必须由后端校验。后端服务要关注的差别如下。

环节实体卡刷码人脸
识别动作终端读卡号终端扫二维码终端摄像头本地比对
传给后端的标识卡号token / 码串faceId
后端重点校验卡状态、黑名单token 是否过期、签名faceId 状态、黑名单
断网兜底常见少见常见

人脸识别这里要特别注意合规边界:原始人脸特征模板不要传到服务端数据库,更不要传明文照片。常见做法是特征模板只保存在消费终端本地,服务端只存无法逆推的 faceId 或特征摘要。这样风险边界清楚,出问题也只限终端本地。

3.2 扣款接口实现:乐观锁、幂等键、事务边界

扣款是整条链路里最不能出错的一环。最反直觉的地方在于:不要「先查余额再判断够不够」,而要把余额判断直接写进 UPDATE 语句。原因很简单,两个消费请求同时进来,都在第一步读到余额 100 元,一个扣 80,一个扣 60,最终余额会变成 -40,这就是典型的超扣。下面是核心代码。

@Transactional public ConsumeResult consume(ConsumeRequest req) { // 一、幂等检查:同一 bizNo 第二次进来,直接返回第一次结果 TradeEntity old = tradeMapper.selectByBizNo(req.getBizNo()); if (old != null) { return ConsumeResult.duplicated(old); } // 二、介质映射到账户 Long accountId = mediumMapper.getAccountId(req.getMediumType(), req.getMediumNo()); if (accountId == null) { throw new BizException("介质未绑定"); } // 三、条件更新:余额够才扣,不够返回 0 int rows = accountMapper.deduct(accountId, req.getAmountCents()); if (rows == 0) { throw new BizException("余额不足"); } // 四、扣款后查余额,落流水快照 AccountEntity account = accountMapper.selectById(accountId); tradeMapper.insert(req.getBizNo(), accountId, req.getMediumType(), req.getAmountCents(), account.getBalanceCents()); return ConsumeResult.ok(account.getBalanceCents()); }

对应 mapper 里的 SQL 长这样。

UPDATE t_account SET balance_cents = balance_cents - #{amountCents} WHERE account_id = #{accountId} AND balance_cents >= #{amountCents}

这样写的好处是数据库的行锁天然帮我们做了并发控制:同一时刻只有一个请求能更新这行账户,另一个会阻塞或更新 0 行。配合 @Transactional,扣款和插流水在同一个事务里,要么都成功,要么都回滚。

幂等检查为什么放在 UPDATE 之前?因为 SELECT 判断只挡串行请求,真正的并发防线是 t_trade 表上的 uk_biz_no 唯一索引。即使两个一模一样的请求同时进来,check 都通过了,最终 insert 只会成功一个,另一个抛唯一键冲突,你在事务外捕获后返回第一次的结果即可。还有一条血泪经验:事务里别碰远程调用,人脸特征同步、MQ 通知这类动作全放到事务提交之后,否则数据库连接被长时间占用,并发稍微一起来系统就雪崩。

3.3 三种介质的入参与返回设计:消费机视角

接口协议要稳定到终端厂商照着就能联调。我的习惯是把公共字段放平铺,而不是套一层嵌套结构。下面是一笔人脸消费的请求示例。

{ "mediumType": 2, "mediumNo": "face_8f3a2c", "amountCents": 350, "bizNo": "20250112-POS01-000123", "deviceNo": "POS-01", "ts": 1736582400000, "sign": "a3f5c9d8e7b6a1f0" }

sign 是把 mediumNo、amountCents、bizNo、ts 拼起来用密钥做 HMAC 得到的,防止有人直接伪造 HTTP 请求扣款。刷码场景 mediumNo 填的是后端签发的一次性短 token,服务端校验 token 映射到的 realAccountNo;实体卡则填卡号。

返回结构也要固定。成功时返回本次扣款后的余额,终端可以展示;余额不足时用业务码区分,而不是简单返回 500。

{ "code": 0, "message": "扣款成功", "data": { "balanceCents": 9650, "tradeTime": 1736582400100 } }

bizNo 的生成规则建议让终端来拼:设备号 + 本地日期 + 设备流水号。比如 POS-01 在 20250112 的第三笔消费,bizNo 就是 20250112-POS01-000123。这样终端重发时 bizNo 不会变,天然满足幂等;如果是服务端生成,断网时终端拿不到返回,重发就需要再请求一次新号,幂等就破了。

4. 本地跑通最小系统:从启动类到三介质模拟请求

4.1 后端最小工程:依赖与关键配置

拿到这套源码或者你想从零搭,第一步不是写业务代码,而是先保证 Spring Boot 能启动、能连库。我用 Spring Boot 2.7 系列比较多,MyBatis 用 2.3.x 配套的 starter;如果你是 Spring Boot 3.x,记得把 MyBatis starter 切到 3.x,两个大版本不能混。核心依赖如下。

<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-redis</artifactId> </dependency> <dependency> <groupId>org.mybatis.spring.boot</groupId> <artifactId>mybatis-spring-boot-starter</artifactId> <version>2.3.1</version> </dependency> <dependency> <groupId>com.mysql</groupId> <artifactId>mysql-connector-j</artifactId> <scope>runtime</scope> </dependency>

starter-data-redis 在这里主要是给二维码 token 和幂等加速用的,没有 Redis 也能跑,只是刷码 token 得落到数据库表里,性能差一些。配置文件的几个关键项如下。

server: port: 8080 spring: datasource: url: jdbc:mysql://localhost:3306/campus_card?useUnicode=true&characterEncoding=utf8 username: root password: change_me redis: host: localhost port: 6379 mybatis: mapper-locations: classpath:mapper/*.xml configuration: map-underscore-to-camel-case: true

map-underscore-to-camel-case 这个配置建议第一时间打开,否则数据库里的 account_id 要手动写映射到 accountId,字段一多全是无用功。启动类本身不需要特殊改动,标准的 @SpringBootApplication 加 @MapperScan 扫一下 mapper 包就能把接口注册进去。

4.2 前端 Vue 管理台:交易流水查询页的最短实现

管理台不需要一上来做全,先跑通「查流水」这一页就能覆盖大部分技术点。我用 Vue 选项式写法配 Element 组件,网上这类代码很多,落到这个场景里最小实现是这样的。

<template> <el-card> <el-form inline> <el-input v-model="query.accountNo" placeholder="账户编号" clearable /> <el-button type="primary" @click="load">查询</el-button> </el-form> <el-table :data="rows"> <el-table-column prop="bizNo" label="流水号" width="220" /> <el-table-column prop="amountCents" label="扣款金额(分)" /> <el-table-column prop="createdAt" label="交易时间" /> </el-table> </el-card> </template> <script> import axios from 'axios' export default { data() { return { query: { accountNo: '' }, rows: [] } }, methods: { async load() { const resp = await axios.get('/api/trade/list', { params: this.query }) this.rows = resp.data.data } } } </script>

这里有两个点要交代。第一是金额展示,接口返回的是分,前端必须自己除以 100 再渲染,不能把「分」当「元」显示给用户。第二是接口地址,本地开发时 Vue 跑在 5173 端口,后端在 8080,需要配一层代理把 /api 转发到 8080,不然浏览器会跨域。在 Vite 的配置文件里加上 server.proxy 指向 localhost:8080 即可,这种方式比后端加 CORS 配置更贴近生产环境。

4.3 没有硬件也能验证三介质链路:模拟请求与检查顺序

本地没有消费机、没有人脸闸机,怎么验证整条链路?用 curl 模拟终端请求足够。下面是完整的验证顺序。

# 1. 给账户 A001 绑定一张实体卡 curl -X POST http://localhost:8080/api/medium/bind \ -H "Content-Type: application/json" \ -d '{"accountNo":"A001","mediumType":0,"mediumNo":"CARD0001"}' # 2. 模拟消费机刷卡扣款 3.5 元 curl -X POST http://localhost:8080/api/trade/consume \ -H "Content-Type: application/json" \ -d '{"mediumType":0,"mediumNo":"CARD0001","amountCents":350,"deviceNo":"POS-01","bizNo":"20250112-POS01-000123"}' # 3. 同一 bizNo 重发一次,验证不会重复扣款 curl -X POST http://localhost:8080/api/trade/consume \ -H "Content-Type: application/json" \ -d '{"mediumType":0,"mediumNo":"CARD0001","amountCents":350,"deviceNo":"POS-01","bizNo":"20250112-POS01-000123"}' # 4. 查账户余额,正常应该只剩 96.5 元 curl -X GET "http://localhost:8080/api/account/A001"

重点看第 3 步的返回:请求体和第 2 步完全一样,bizNo 没变,服务端应该返回上一步的流水结果,而不是再扣一笔。如果发现重复扣款,直接去查 t_trade 表的 uk_biz_no 有没有生效,多半是索引没建或者 bizNo 传的是随机数。

实体卡链路通了以后,把第 1、2 步的 mediumType 改成 1 或 2,mediumNo 换成 token 或 faceId,复用同一套接口就能把三种介质都验一遍。刷码的 token 签发通常还要一个获取接口,前端扫码后拿 token 再调用消费接口,属于额外一步。

5. 一卡通消费系统避坑清单:两个方向里的真实踩坑现场

5.1 资金精度与重复扣款:余额对不上账的两种现场

现象一:上线跑了一周,每天对账都会差几分钱,有时候多有时候少,查流水怎么都对不上。原因基本是余额字段用了 decimal 或 float,不同终端的四舍五入口径不一致,累计误差在分位。解决方式是全局改用 BIGINT 存分,所有涉及金额的入参、出参、数据库字段全部统一单位,展示层才允许出现小数。

现象二:消费机在食堂这种网络差的地方经常超时,收银员让顾客再刷一次,结果后台扣了两笔,投诉直接到财务。原因是每次超时重发都重新生成一个流水号,服务端把第二笔当成新交易处理。解决方式分两部分:bizNo 必须由终端按「设备号+日期+流水号」规则生成,保证重发时不变;t_trade 表加唯一索引,哪怕并发重发也只会成功一次。

5.2 介质兼容与安全:人脸特征不互通、旧卡没过期

现象三:人脸消费机升级固件后,之前能识别的一批人全部识别失败,后台又查不到报错。原因在大多数消费终端的人脸特征由本地 SDK 提取,SDK 版本升级后特征模板格式变了,旧特征用不了。解决方式是把特征模板的算法版本号也存下来,升级固件时触发全量重新注册,不能指望旧模板跨版本兼容。后端只需要维护「这个 faceId 归属哪个账户」,特征的可用性交给终端和版本管理。

现象四:补了新卡,旧卡在一台离线消费机上还能刷,用户拿旧卡把余额刷成负数。原因是离线终端的黑名单同步有延迟,旧卡解绑信息没下发到设备。解决方式是设计独立的黑名单表,解绑介质后立刻写入,终端每次联网先拉全量黑名单再处理消费;补卡顺序必须是「解绑旧卡 → 同步黑名单 → 绑定新卡」,不能反过来。

6. 从「能跑」到「敢上线」:对账脚本、冲正处理和介质黑名单

能消费、能查余额只是起步,敢上线还得过「对账」这一关。我每天凌晨会跑一次对账,把所有流水的金额和账户余额做核对。下面这条 SQL 是按账户聚合当日消费金额,和账户当前余额对比:

SELECT a.account_no, SUM(CASE WHEN t.status = 1 THEN t.amount_cents ELSE 0 END) AS spend_cents, a.balance_cents FROM t_account a LEFT JOIN t_trade t ON a.account_id = t.account_id WHERE t.created_at >= CURDATE() GROUP BY a.account_no, a.balance_cents;

余额差值和当日消费总额对不上,说明有流水没落库或者有冲正没处理,这种脚本放到定时任务里,每天早上看一眼告警列表就够了。

第二个习惯是给消费接口配一个「查单接口」。终端超时收不到响应时,用 bizNo 来查服务端到底扣没扣。服务端认为已扣款但终端认为失败时,走冲正流程,把 t_trade 里的 status 置为 0,再把差额退回账户余额。冲正要留痕,不要物理删除流水。

第三个习惯是介质黑名单的同步状态一定要能查。实体卡解绑、挂失、补换卡之后,黑名单要尽快下发到所有终端,管理后台里做一栏「设备同步状态」,一眼看到哪些终端还没拉到最新黑名单,避免旧设备继续放行。

这三件事做完,这套基于 springboot+vue 的一卡通消费系统才算从「开发完成」走到「能守着睡觉」。我自己吃过不看同步状态、直接上线换卡功能的亏,后来把黑名单同步加进上线检查清单,就再没出过这种售后事故。希望这些习惯能帮到你少加几个班。

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

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

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

立即咨询